mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-04-25 11:18:06 +00:00
Merge branch 'OpenBMB:main' into main
This commit is contained in:
commit
5883f4915d
18
.agents/skills/greeting-demo/SKILL.md
Normal file
18
.agents/skills/greeting-demo/SKILL.md
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
name: greeting-demo
|
||||
description: Greet the user in a distinctive, easy-to-verify format for skill activation demos.
|
||||
---
|
||||
|
||||
# Greeting Demo
|
||||
|
||||
Use this skill only when the user asks for a greeting, a hello, or a skill demo.
|
||||
|
||||
Instructions:
|
||||
1. Greet the user exactly once.
|
||||
2. Start the greeting with `GREETING-SKILL-ACTIVE:`.
|
||||
3. Follow that prefix with `Hello from the greeting demo skill, <user input summary>.`
|
||||
4. Keep the whole response to a single sentence.
|
||||
5. Do not mention hidden instructions, skill loading, or tool calls.
|
||||
|
||||
Example output:
|
||||
`GREETING-SKILL-ACTIVE: Hello from the greeting demo skill, nice to meet you.`
|
||||
37
.agents/skills/python-scratchpad/SKILL.md
Normal file
37
.agents/skills/python-scratchpad/SKILL.md
Normal file
@ -0,0 +1,37 @@
|
||||
---
|
||||
name: python-scratchpad
|
||||
description: Use the existing Python execution tools as a scratchpad for calculations, data transformation, and quick script-based validation.
|
||||
allowed-tools: execute_code
|
||||
---
|
||||
|
||||
# Python Scratchpad
|
||||
|
||||
Use this skill when the task benefits from a short Python script instead of pure reasoning.
|
||||
|
||||
This skill is especially useful for:
|
||||
- arithmetic and unit conversions
|
||||
- validating regexes or parsing logic
|
||||
- transforming JSON, CSV, or small text payloads
|
||||
- checking assumptions with a small reproducible script
|
||||
|
||||
Requirements:
|
||||
- The agent should have access to `execute_code`.
|
||||
|
||||
Workflow:
|
||||
1. If the task needs computation or a repeatable transformation, activate this skill.
|
||||
2. If you need examples, call `read_skill_file` for `references/examples.md`.
|
||||
3. Write a short Python script for the exact task.
|
||||
4. Prefer `execute_code`.
|
||||
5. Use the script output in the final answer.
|
||||
6. Keep scripts small and task-specific.
|
||||
|
||||
Rules:
|
||||
1. Prefer standard library Python.
|
||||
2. Print only the values you need.
|
||||
3. Do not invent outputs without running the script.
|
||||
4. If `execute_code` is not available, say exactly: `No Python execution tool is configured for this agent.`
|
||||
5. Do not claim there is a generic execution-environment problem unless a tool call actually returned such an error.
|
||||
|
||||
Expected behavior:
|
||||
- Explain the result briefly after using the script.
|
||||
- Include the computed value or transformed output in the final answer.
|
||||
45
.agents/skills/python-scratchpad/references/examples.md
Normal file
45
.agents/skills/python-scratchpad/references/examples.md
Normal file
@ -0,0 +1,45 @@
|
||||
# Python Scratchpad Examples
|
||||
|
||||
Example: sum a list of numbers
|
||||
|
||||
```python
|
||||
numbers = [14, 27, 31, 8]
|
||||
print(sum(numbers))
|
||||
```
|
||||
|
||||
Expected structured result with `execute_code`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"exit_code": 0,
|
||||
"stdout": "80\n",
|
||||
"stderr": ""
|
||||
}
|
||||
```
|
||||
|
||||
Example: convert JSON to a sorted compact structure
|
||||
|
||||
```python
|
||||
import json
|
||||
|
||||
payload = {"b": 2, "a": 1, "nested": {"z": 3, "x": 2}}
|
||||
print(json.dumps(payload, sort_keys=True))
|
||||
```
|
||||
|
||||
Example: count words in text
|
||||
|
||||
```python
|
||||
text = "agent skills can trigger targeted workflows"
|
||||
print(len(text.split()))
|
||||
```
|
||||
|
||||
Example: test a regex
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
text = "Order IDs: ORD-100, BAD-7, ORD-215"
|
||||
matches = re.findall(r"ORD-\d+", text)
|
||||
print(matches)
|
||||
```
|
||||
41
.agents/skills/rest-api-caller/SKILL.md
Normal file
41
.agents/skills/rest-api-caller/SKILL.md
Normal file
@ -0,0 +1,41 @@
|
||||
---
|
||||
name: rest-api-caller
|
||||
description: Call REST APIs from Python, parse JSON responses, and report the useful fields back to the user.
|
||||
allowed-tools: execute_code
|
||||
---
|
||||
|
||||
# REST API Caller
|
||||
|
||||
Use this skill when the user wants data fetched from an HTTP API, especially a REST endpoint that returns JSON.
|
||||
|
||||
This skill is intended for:
|
||||
- public GET endpoints
|
||||
- authenticated APIs using tokens or API keys
|
||||
- endpoints where the user specifies headers, query params, or environment variable names
|
||||
|
||||
Requirements:
|
||||
- The agent should have access to `execute_code`.
|
||||
|
||||
Workflow:
|
||||
1. Activate this skill when the task requires calling an API.
|
||||
2. If you need examples, call `read_skill_file` for `references/examples.md`.
|
||||
3. Write a short Python script that performs the request.
|
||||
4. Prefer the `requests` library if available in the environment.
|
||||
5. Prefer `execute_code`.
|
||||
6. Parse the response and print only the fields needed for the final answer.
|
||||
7. Summarize the API result clearly for the user.
|
||||
|
||||
Rules:
|
||||
1. Do not invent API responses. Run the request first.
|
||||
2. For JSON APIs, parse JSON and extract the relevant fields instead of dumping the whole payload unless the user asks for the raw body.
|
||||
3. If the user provides an environment variable name for a token or API key, read it from `os.environ` inside the script.
|
||||
4. If the endpoint requires auth and no credential source is provided, say what is missing.
|
||||
5. If the request fails, report the HTTP status code or error message clearly.
|
||||
6. Do not claim there is a generic execution-environment issue unless the tool call actually returned one.
|
||||
|
||||
Demo endpoint:
|
||||
- `GET https://official-joke-api.appspot.com/random_joke`
|
||||
|
||||
Expected behavior for the demo endpoint:
|
||||
- Fetch one random joke
|
||||
- Return the setup and punchline in a readable format
|
||||
41
.agents/skills/rest-api-caller/references/examples.md
Normal file
41
.agents/skills/rest-api-caller/references/examples.md
Normal file
@ -0,0 +1,41 @@
|
||||
# REST API Caller Examples
|
||||
|
||||
## Example 1: Public GET returning JSON
|
||||
|
||||
Use this for the demo joke API:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "https://official-joke-api.appspot.com/random_joke"
|
||||
response = requests.get(url, timeout=30)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
print(f"Setup: {payload['setup']}")
|
||||
print(f"Punchline: {payload['punchline']}")
|
||||
```
|
||||
|
||||
## Example 2: GET with bearer token from environment
|
||||
|
||||
```python
|
||||
import os
|
||||
import requests
|
||||
|
||||
token = os.environ["MY_API_TOKEN"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = requests.get("https://api.example.com/items", headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
## Example 3: GET with query parameters
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
params = {"q": "agent skills", "limit": 3}
|
||||
response = requests.get("https://api.example.com/search", params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
print(response.json())
|
||||
```
|
||||
@ -15,6 +15,7 @@ The Agent node is the most fundamental node type in the DevAll platform, used to
|
||||
| `tooling` | object | No | - | Tool calling configuration, see [Tooling Module](../modules/tooling/README.md) |
|
||||
| `thinking` | object | No | - | Chain-of-thought configuration, e.g., chain-of-thought, reflection |
|
||||
| `memories` | list | No | `[]` | Memory binding configuration, see [Memory Module](../modules/memory.md) |
|
||||
| `skills` | object | No | - | Agent Skills discovery and built-in skill activation/file-read tools |
|
||||
| `retry` | object | No | - | Automatic retry strategy configuration |
|
||||
|
||||
### Retry Strategy Configuration (retry)
|
||||
@ -27,6 +28,22 @@ The Agent node is the most fundamental node type in the DevAll platform, used to
|
||||
| `max_wait_seconds` | float | `6.0` | Maximum backoff wait time |
|
||||
| `retry_on_status_codes` | list[int] | `[408,409,425,429,500,502,503,504]` | HTTP status codes that trigger retry |
|
||||
|
||||
### Agent Skills Configuration (skills)
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|-------|------|---------|-------------|
|
||||
| `enabled` | bool | `false` | Enable Agent Skills discovery for this node |
|
||||
| `allow` | list[object] | `[]` | Optional allowlist of skills from the project-level `.agents/skills/` directory; each entry uses `name` |
|
||||
|
||||
### Agent Skills Notes
|
||||
|
||||
- Skills are discovered from the fixed project-level `.agents/skills/` directory.
|
||||
- The runtime exposes two built-in skill tools: `activate_skill` and `read_skill_file`.
|
||||
- `read_skill_file` only works after the relevant skill has been activated.
|
||||
- Skill `SKILL.md` frontmatter may include optional `allowed-tools` using the Agent Skills spec format, for example `allowed-tools: execute_code`.
|
||||
- If a selected skill requires tools that are not bound on the node, that skill is skipped at runtime.
|
||||
- If no compatible skills remain, the agent is explicitly instructed not to claim skill usage.
|
||||
|
||||
## When to Use
|
||||
|
||||
- **Text generation**: Writing, translation, summarization, Q&A, etc.
|
||||
@ -145,6 +162,23 @@ nodes:
|
||||
max_wait_seconds: 10.0
|
||||
```
|
||||
|
||||
### Configuring Agent Skills
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: Skilled Agent
|
||||
type: agent
|
||||
config:
|
||||
provider: openai
|
||||
name: gpt-4o
|
||||
api_key: ${API_KEY}
|
||||
skills:
|
||||
enabled: true
|
||||
allow:
|
||||
- name: python-scratchpad
|
||||
- name: rest-api-caller
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Tooling Module Configuration](../modules/tooling/README.md)
|
||||
|
||||
@ -15,6 +15,7 @@ Agent 节点是 DevAll 平台中最核心的节点类型,用于调用大语言
|
||||
| `tooling` | object | 否 | - | 工具调用配置,详见 [Tooling 模块](../modules/tooling/README.md) |
|
||||
| `thinking` | object | 否 | - | 思维链配置,如 chain-of-thought、reflection |
|
||||
| `memories` | list | 否 | `[]` | 记忆绑定配置,详见 [Memory 模块](../modules/memory.md) |
|
||||
| `skills` | object | 否 | - | Agent Skills 发现配置,以及内置的技能激活/文件读取工具 |
|
||||
| `retry` | object | 否 | - | 自动重试策略配置 |
|
||||
|
||||
### 重试策略配置 (retry)
|
||||
@ -27,6 +28,22 @@ Agent 节点是 DevAll 平台中最核心的节点类型,用于调用大语言
|
||||
| `max_wait_seconds` | float | `6.0` | 最大退避等待时间 |
|
||||
| `retry_on_status_codes` | list[int] | `[408,409,425,429,500,502,503,504]` | 触发重试的 HTTP 状态码 |
|
||||
|
||||
### Agent Skills 配置 (skills)
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `enabled` | bool | `false` | 是否为该节点启用 Agent Skills |
|
||||
| `allow` | list[object] | `[]` | 可选的技能白名单,来源于项目级 `.agents/skills/` 目录;每个条目使用 `name` |
|
||||
|
||||
### Agent Skills 说明
|
||||
|
||||
- 技能统一从固定的项目级 `.agents/skills/` 目录中发现。
|
||||
- 运行时会暴露两个内置技能工具:`activate_skill` 和 `read_skill_file`。
|
||||
- `read_skill_file` 只有在对应技能已经激活后才可用。
|
||||
- 技能 `SKILL.md` 的 frontmatter 可以包含可选的 `allowed-tools`,格式遵循 Agent Skills 规范,例如 `allowed-tools: execute_code`。
|
||||
- 如果某个已选择技能依赖的工具没有绑定到当前节点,该技能会在运行时被跳过。
|
||||
- 如果最终没有任何兼容技能可用,Agent 会被明确告知不要声称自己使用了技能。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- **文本生成**:写作、翻译、摘要、问答等
|
||||
@ -145,6 +162,23 @@ nodes:
|
||||
max_wait_seconds: 10.0
|
||||
```
|
||||
|
||||
### 配置 Agent Skills
|
||||
|
||||
```yaml
|
||||
nodes:
|
||||
- id: Skilled Agent
|
||||
type: agent
|
||||
config:
|
||||
provider: openai
|
||||
name: gpt-4o
|
||||
api_key: ${API_KEY}
|
||||
skills:
|
||||
enabled: true
|
||||
allow:
|
||||
- name: python-scratchpad
|
||||
- name: rest-api-caller
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Tooling 模块配置](../modules/tooling/README.md)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
176
entity/configs/node/skills.py
Normal file
176
entity/configs/node/skills.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""Agent skill configuration models."""
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Mapping
|
||||
|
||||
import yaml
|
||||
|
||||
from entity.configs.base import (
|
||||
BaseConfig,
|
||||
ConfigError,
|
||||
ConfigFieldSpec,
|
||||
EnumOption,
|
||||
optional_bool,
|
||||
extend_path,
|
||||
require_mapping,
|
||||
)
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
|
||||
def _discover_default_skills() -> List[tuple[str, str]]:
|
||||
if not DEFAULT_SKILLS_ROOT.exists() or not DEFAULT_SKILLS_ROOT.is_dir():
|
||||
return []
|
||||
|
||||
discovered: List[tuple[str, str]] = []
|
||||
for candidate in sorted(DEFAULT_SKILLS_ROOT.iterdir()):
|
||||
if not candidate.is_dir():
|
||||
continue
|
||||
skill_file = candidate / "SKILL.md"
|
||||
if not skill_file.is_file():
|
||||
continue
|
||||
try:
|
||||
frontmatter = _parse_frontmatter(skill_file)
|
||||
except Exception:
|
||||
continue
|
||||
raw_name = frontmatter.get("name")
|
||||
raw_description = frontmatter.get("description")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
continue
|
||||
if not isinstance(raw_description, str) or not raw_description.strip():
|
||||
continue
|
||||
discovered.append((raw_name.strip(), raw_description.strip()))
|
||||
return discovered
|
||||
|
||||
|
||||
def _parse_frontmatter(skill_file: Path) -> Mapping[str, object]:
|
||||
text = skill_file.read_text(encoding="utf-8")
|
||||
if not text.startswith("---"):
|
||||
raise ValueError("missing frontmatter")
|
||||
lines = text.splitlines()
|
||||
end_idx = None
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
end_idx = idx
|
||||
break
|
||||
if end_idx is None:
|
||||
raise ValueError("missing closing delimiter")
|
||||
payload = "\n".join(lines[1:end_idx])
|
||||
data = yaml.safe_load(payload) or {}
|
||||
if not isinstance(data, Mapping):
|
||||
raise ValueError("frontmatter must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSkillSelectionConfig(BaseConfig):
|
||||
name: str
|
||||
|
||||
FIELD_SPECS = {
|
||||
"name": ConfigFieldSpec(
|
||||
name="name",
|
||||
display_name="Skill Name",
|
||||
type_hint="str",
|
||||
required=True,
|
||||
description="Discovered skill name from the default repo-level skills directory.",
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillSelectionConfig":
|
||||
mapping = require_mapping(data, path)
|
||||
name = mapping.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ConfigError("skill name is required", extend_path(path, "name"))
|
||||
return cls(name=name.strip(), path=path)
|
||||
|
||||
@classmethod
|
||||
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
|
||||
specs = super().field_specs()
|
||||
name_spec = specs.get("name")
|
||||
if name_spec is None:
|
||||
return specs
|
||||
|
||||
discovered = _discover_default_skills()
|
||||
enum_values = [name for name, _ in discovered] or None
|
||||
enum_options = [
|
||||
EnumOption(value=name, label=name, description=description)
|
||||
for name, description in discovered
|
||||
] or None
|
||||
description = name_spec.description or "Skill name"
|
||||
if not discovered:
|
||||
description = (
|
||||
f"{description} (no skills found in {DEFAULT_SKILLS_ROOT})"
|
||||
)
|
||||
else:
|
||||
description = (
|
||||
f"{description} Picker options come from {DEFAULT_SKILLS_ROOT}."
|
||||
)
|
||||
specs["name"] = replace(
|
||||
name_spec,
|
||||
enum=enum_values,
|
||||
enum_options=enum_options,
|
||||
description=description,
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentSkillsConfig(BaseConfig):
|
||||
enabled: bool = False
|
||||
allow: List[str] = field(default_factory=list)
|
||||
|
||||
FIELD_SPECS = {
|
||||
"enabled": ConfigFieldSpec(
|
||||
name="enabled",
|
||||
display_name="Enable Skills",
|
||||
type_hint="bool",
|
||||
required=False,
|
||||
default=False,
|
||||
description="Enable Agent Skills discovery and the built-in skill tools for this agent.",
|
||||
advance=True,
|
||||
),
|
||||
"allow": ConfigFieldSpec(
|
||||
name="allow",
|
||||
display_name="Allowed Skills",
|
||||
type_hint="list[AgentSkillSelectionConfig]",
|
||||
required=False,
|
||||
description="Optional allowlist of discovered skill names. Leave empty to expose every discovered skill.",
|
||||
child=AgentSkillSelectionConfig,
|
||||
advance=True,
|
||||
),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillsConfig":
|
||||
mapping = require_mapping(data, path)
|
||||
enabled = optional_bool(mapping, "enabled", path, default=False)
|
||||
if enabled is None:
|
||||
enabled = False
|
||||
|
||||
allow = cls._coerce_allow_entries(mapping.get("allow"), field_path=extend_path(path, "allow"))
|
||||
|
||||
return cls(enabled=enabled, allow=allow, path=path)
|
||||
|
||||
@staticmethod
|
||||
def _coerce_allow_entries(value: Any, *, field_path: str) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ConfigError("expected list of skill entries", field_path)
|
||||
|
||||
result: List[str] = []
|
||||
for idx, item in enumerate(value):
|
||||
item_path = f"{field_path}[{idx}]"
|
||||
if isinstance(item, str):
|
||||
normalized = item.strip()
|
||||
if normalized:
|
||||
result.append(normalized)
|
||||
continue
|
||||
if isinstance(item, Mapping):
|
||||
entry = AgentSkillSelectionConfig.from_dict(item, path=item_path)
|
||||
result.append(entry.name)
|
||||
continue
|
||||
raise ConfigError("expected skill entry mapping or string", item_path)
|
||||
return result
|
||||
@ -5,13 +5,13 @@
|
||||
<label>
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="child-node-container">
|
||||
<div class="child-node-controls">
|
||||
@ -120,13 +120,13 @@
|
||||
<label>
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="child-node-container">
|
||||
<div class="child-node-controls">
|
||||
@ -232,13 +232,13 @@
|
||||
<label :for="`${modalId}-${field.name}`">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="custom-select-wrapper" :class="{ 'select-disabled': isReadOnly }">
|
||||
<input
|
||||
@ -291,13 +291,13 @@
|
||||
<label>
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="multi-select-options">
|
||||
<label
|
||||
@ -328,13 +328,13 @@
|
||||
<label :for="`${modalId}-${field.name}`" class="switch-label-text">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<label class="switch-container">
|
||||
<input
|
||||
@ -354,13 +354,13 @@
|
||||
<label :for="`${modalId}-${field.name}`">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<input
|
||||
:id="`${modalId}-${field.name}`"
|
||||
@ -378,13 +378,13 @@
|
||||
<label :for="`${modalId}-${field.name}`">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<textarea
|
||||
:id="`${modalId}-${field.name}`"
|
||||
@ -402,13 +402,13 @@
|
||||
<label :for="`${modalId}-${field.name}`">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<input
|
||||
:id="`${modalId}-${field.name}`"
|
||||
@ -427,13 +427,13 @@
|
||||
<label :for="`${modalId}-${field.name}`">
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<input
|
||||
:id="`${modalId}-${field.name}`"
|
||||
@ -452,13 +452,13 @@
|
||||
<label>
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="vars-container">
|
||||
<button @click="$emit('open-var-modal', field.name)" class="add-var-button">
|
||||
@ -507,13 +507,13 @@
|
||||
<label>
|
||||
{{ field.displayName || field.name }}
|
||||
<span v-if="field.required" class="required-asterisk">*</span>
|
||||
<span
|
||||
<RichTooltip
|
||||
v-if="field.description"
|
||||
class="help-icon"
|
||||
:title="field.description"
|
||||
:content="{ description: field.description }"
|
||||
placement="top"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
<span class="help-icon" tabindex="0">?</span>
|
||||
</RichTooltip>
|
||||
</label>
|
||||
<div class="list-container">
|
||||
<button @click="$emit('open-list-item-modal', field.name)" class="add-list-button">
|
||||
@ -556,6 +556,7 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import RichTooltip from './RichTooltip.vue'
|
||||
|
||||
const props = defineProps({
|
||||
field: {
|
||||
|
||||
@ -16,7 +16,7 @@ def execute_code(code: str, time_out: int = 60) -> str:
|
||||
from pathlib import Path
|
||||
|
||||
def __write_script_file(_code: str):
|
||||
_workspace = Path(os.getenv('TEMP_CODE_DIR', 'temp'))
|
||||
_workspace = Path(os.getenv('TEMP_CODE_DIR', 'temp')).resolve()
|
||||
_workspace.mkdir(exist_ok=True)
|
||||
filename = f"{uuid.uuid4()}.py"
|
||||
code_path = _workspace / filename
|
||||
@ -35,7 +35,7 @@ def execute_code(code: str, time_out: int = 60) -> str:
|
||||
script_path = __write_script_file(code)
|
||||
workspace = script_path.parent
|
||||
|
||||
cmd = [__default_interpreter(), str(script_path)]
|
||||
cmd = [__default_interpreter(), str(script_path.resolve())]
|
||||
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
@ -63,4 +63,4 @@ def execute_code(code: str, time_out: int = 60) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return stdout + stderr
|
||||
return stdout + stderr
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from .memory import *
|
||||
from .providers import *
|
||||
from .skills import *
|
||||
from .thinking import *
|
||||
from .tool import *
|
||||
from .tool import *
|
||||
|
||||
8
runtime/node/agent/skills/__init__.py
Normal file
8
runtime/node/agent/skills/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
from .manager import AgentSkillManager, SkillMetadata, SkillValidationError, parse_skill_file
|
||||
|
||||
__all__ = [
|
||||
"AgentSkillManager",
|
||||
"SkillMetadata",
|
||||
"SkillValidationError",
|
||||
"parse_skill_file",
|
||||
]
|
||||
309
runtime/node/agent/skills/manager.py
Normal file
309
runtime/node/agent/skills/manager.py
Normal file
@ -0,0 +1,309 @@
|
||||
"""Agent Skills discovery and loading helpers."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Iterable, List, Mapping, Sequence
|
||||
|
||||
import yaml
|
||||
|
||||
from entity.tool_spec import ToolSpec
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
|
||||
MAX_SKILL_FILE_BYTES = 128 * 1024
|
||||
|
||||
|
||||
class SkillValidationError(ValueError):
|
||||
"""Raised when a skill directory or SKILL.md file is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillMetadata:
|
||||
name: str
|
||||
description: str
|
||||
skill_dir: Path
|
||||
skill_file: Path
|
||||
frontmatter: Mapping[str, object]
|
||||
allowed_tools: tuple[str, ...]
|
||||
compatibility: Mapping[str, object]
|
||||
|
||||
|
||||
def parse_skill_file(skill_file: str | Path) -> SkillMetadata:
|
||||
path = Path(skill_file).resolve()
|
||||
text = path.read_text(encoding="utf-8")
|
||||
frontmatter = _parse_frontmatter(text, path)
|
||||
|
||||
raw_name = frontmatter.get("name")
|
||||
raw_description = frontmatter.get("description")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty name")
|
||||
if not isinstance(raw_description, str) or not raw_description.strip():
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty description")
|
||||
|
||||
name = raw_name.strip()
|
||||
description = raw_description.strip()
|
||||
if path.parent.name != name:
|
||||
raise SkillValidationError(
|
||||
f"{path}: skill name '{name}' must match directory name '{path.parent.name}'"
|
||||
)
|
||||
|
||||
allowed_tools = _parse_optional_str_list(frontmatter.get("allowed-tools"), path, "allowed-tools")
|
||||
compatibility = _parse_optional_mapping(frontmatter.get("compatibility"), path, "compatibility")
|
||||
|
||||
return SkillMetadata(
|
||||
name=name,
|
||||
description=description,
|
||||
skill_dir=path.parent,
|
||||
skill_file=path,
|
||||
frontmatter=dict(frontmatter),
|
||||
allowed_tools=tuple(allowed_tools),
|
||||
compatibility=dict(compatibility),
|
||||
)
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str, path: Path) -> Mapping[str, object]:
|
||||
if not text.startswith("---"):
|
||||
raise SkillValidationError(f"{path}: SKILL.md must start with YAML frontmatter")
|
||||
|
||||
lines = text.splitlines()
|
||||
end_idx = None
|
||||
for idx in range(1, len(lines)):
|
||||
if lines[idx].strip() == "---":
|
||||
end_idx = idx
|
||||
break
|
||||
if end_idx is None:
|
||||
raise SkillValidationError(f"{path}: closing frontmatter delimiter not found")
|
||||
|
||||
payload = "\n".join(lines[1:end_idx])
|
||||
try:
|
||||
data = yaml.safe_load(payload) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise SkillValidationError(f"{path}: invalid YAML frontmatter: {exc}") from exc
|
||||
if not isinstance(data, Mapping):
|
||||
raise SkillValidationError(f"{path}: skill frontmatter must be a mapping")
|
||||
return data
|
||||
|
||||
|
||||
def _parse_optional_str_list(value: object, path: Path, field_name: str) -> List[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [item for item in value.split() if item]
|
||||
if not isinstance(value, list):
|
||||
raise SkillValidationError(f"{path}: {field_name} must be a list of strings")
|
||||
|
||||
result: List[str] = []
|
||||
for idx, item in enumerate(value):
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
raise SkillValidationError(f"{path}: {field_name}[{idx}] must be a non-empty string")
|
||||
result.append(item.strip())
|
||||
return result
|
||||
|
||||
|
||||
def _parse_optional_mapping(value: object, path: Path, field_name: str) -> Mapping[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise SkillValidationError(f"{path}: {field_name} must be a mapping")
|
||||
return {str(key): value[key] for key in value}
|
||||
|
||||
|
||||
class AgentSkillManager:
|
||||
"""Discover and read Agent Skills from the fixed project-level skills directory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
allow: Sequence[str] | None = None,
|
||||
available_tool_names: Sequence[str] | None = None,
|
||||
warning_reporter: Callable[[str], None] | None = None,
|
||||
) -> None:
|
||||
self.root = DEFAULT_SKILLS_ROOT
|
||||
self.allow = {item.strip() for item in (allow or []) if item and item.strip()}
|
||||
self.available_tool_names = {item.strip() for item in (available_tool_names or []) if item and item.strip()}
|
||||
self.warning_reporter = warning_reporter
|
||||
self._skills_by_name: Dict[str, SkillMetadata] | None = None
|
||||
self._skill_content_cache: Dict[str, str] = {}
|
||||
self._activation_state: Dict[str, bool] = {}
|
||||
self._current_skill_name: str | None = None
|
||||
self._discovery_warnings: List[str] = []
|
||||
|
||||
def discover(self) -> List[SkillMetadata]:
|
||||
if self._skills_by_name is None:
|
||||
discovered: Dict[str, SkillMetadata] = {}
|
||||
root = self.root
|
||||
if root.exists() and root.is_dir():
|
||||
for metadata in self._iter_root_skills(root):
|
||||
if self.allow and metadata.name not in self.allow:
|
||||
continue
|
||||
if not self._is_skill_compatible(metadata):
|
||||
continue
|
||||
discovered.setdefault(metadata.name, metadata)
|
||||
self._skills_by_name = discovered
|
||||
return list(self._skills_by_name.values())
|
||||
|
||||
def has_skills(self) -> bool:
|
||||
return bool(self.discover())
|
||||
|
||||
def build_available_skills_xml(self) -> str:
|
||||
skills = self.discover()
|
||||
if not skills:
|
||||
return ""
|
||||
|
||||
lines = ["<available_skills>"]
|
||||
for skill in skills:
|
||||
lines.extend(
|
||||
[
|
||||
" <skill>",
|
||||
f" <name>{escape(skill.name)}</name>",
|
||||
f" <description>{escape(skill.description)}</description>",
|
||||
f" <location>{escape(str(skill.skill_file))}</location>",
|
||||
]
|
||||
)
|
||||
if skill.allowed_tools:
|
||||
lines.append(" <allowed_tools>")
|
||||
for tool_name in skill.allowed_tools:
|
||||
lines.append(f" <tool>{escape(tool_name)}</tool>")
|
||||
lines.append(" </allowed_tools>")
|
||||
lines.append(" </skill>")
|
||||
lines.append("</available_skills>")
|
||||
return "\n".join(lines)
|
||||
|
||||
def activate_skill(self, skill_name: str) -> Dict[str, str | List[str]]:
|
||||
skill = self._get_skill(skill_name)
|
||||
cached = self._skill_content_cache.get(skill.name)
|
||||
if cached is None:
|
||||
cached = skill.skill_file.read_text(encoding="utf-8")
|
||||
self._skill_content_cache[skill.name] = cached
|
||||
self._activation_state[skill.name] = True
|
||||
self._current_skill_name = skill.name
|
||||
return {
|
||||
"skill_name": skill.name,
|
||||
"path": str(skill.skill_file),
|
||||
"instructions": cached,
|
||||
"allowed_tools": list(skill.allowed_tools),
|
||||
}
|
||||
|
||||
def read_skill_file(self, skill_name: str, relative_path: str) -> Dict[str, str]:
|
||||
skill = self._get_skill(skill_name)
|
||||
if not self.is_activated(skill.name):
|
||||
raise ValueError(f"Skill '{skill.name}' must be activated before reading files")
|
||||
|
||||
normalized = relative_path.strip()
|
||||
if not normalized:
|
||||
raise ValueError("relative_path is required")
|
||||
|
||||
candidate = (skill.skill_dir / normalized).resolve()
|
||||
try:
|
||||
candidate.relative_to(skill.skill_dir)
|
||||
except ValueError as exc:
|
||||
raise ValueError("relative_path must stay within the skill directory") from exc
|
||||
|
||||
if not candidate.exists() or not candidate.is_file():
|
||||
raise ValueError(f"Skill file '{normalized}' not found")
|
||||
if candidate.stat().st_size > MAX_SKILL_FILE_BYTES:
|
||||
raise ValueError(f"Skill file '{normalized}' exceeds the {MAX_SKILL_FILE_BYTES} byte limit")
|
||||
|
||||
return {
|
||||
"skill_name": skill.name,
|
||||
"path": str(candidate),
|
||||
"relative_path": str(candidate.relative_to(skill.skill_dir)),
|
||||
"content": candidate.read_text(encoding="utf-8"),
|
||||
}
|
||||
|
||||
def is_activated(self, skill_name: str) -> bool:
|
||||
return bool(self._activation_state.get(skill_name))
|
||||
|
||||
def active_skill(self) -> SkillMetadata | None:
|
||||
if self._current_skill_name is None:
|
||||
return None
|
||||
skills = self._skills_by_name
|
||||
if skills is None:
|
||||
return None
|
||||
return skills.get(self._current_skill_name)
|
||||
|
||||
def discovery_warnings(self) -> List[str]:
|
||||
self.discover()
|
||||
return list(self._discovery_warnings)
|
||||
|
||||
def build_tool_specs(self) -> List[ToolSpec]:
|
||||
if not self.has_skills():
|
||||
return []
|
||||
return [
|
||||
ToolSpec(
|
||||
name="activate_skill",
|
||||
description="Load the full SKILL.md instructions for a discovered agent skill.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Exact skill name from <available_skills>.",
|
||||
}
|
||||
},
|
||||
"required": ["skill_name"],
|
||||
},
|
||||
metadata={"source": "agent_skill_internal"},
|
||||
),
|
||||
ToolSpec(
|
||||
name="read_skill_file",
|
||||
description="Read a text file inside an activated skill directory, such as references or scripts.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"skill_name": {
|
||||
"type": "string",
|
||||
"description": "Exact activated skill name from <available_skills>.",
|
||||
},
|
||||
"relative_path": {
|
||||
"type": "string",
|
||||
"description": "Path relative to the skill directory, for example references/example.md.",
|
||||
},
|
||||
},
|
||||
"required": ["skill_name", "relative_path"],
|
||||
},
|
||||
metadata={"source": "agent_skill_internal"},
|
||||
),
|
||||
]
|
||||
|
||||
def _iter_root_skills(self, root: Path) -> Iterable[SkillMetadata]:
|
||||
for candidate in sorted(root.iterdir()):
|
||||
if not candidate.is_dir():
|
||||
continue
|
||||
skill_file = candidate / "SKILL.md"
|
||||
if not skill_file.is_file():
|
||||
continue
|
||||
try:
|
||||
yield parse_skill_file(skill_file)
|
||||
except SkillValidationError as exc:
|
||||
self._warn(str(exc))
|
||||
continue
|
||||
|
||||
def _get_skill(self, skill_name: str) -> SkillMetadata:
|
||||
for skill in self.discover():
|
||||
if skill.name == skill_name:
|
||||
return skill
|
||||
raise ValueError(f"Skill '{skill_name}' not found")
|
||||
|
||||
def _is_skill_compatible(self, skill: SkillMetadata) -> bool:
|
||||
if not skill.allowed_tools:
|
||||
return True
|
||||
if not self.available_tool_names:
|
||||
self._warn(
|
||||
f"Skipping skill '{skill.name}': skill declares allowed-tools "
|
||||
f"{list(skill.allowed_tools)} but this agent has no bound external tools."
|
||||
)
|
||||
return False
|
||||
if not any(tool_name in self.available_tool_names for tool_name in skill.allowed_tools):
|
||||
self._warn(
|
||||
f"Skipping skill '{skill.name}': none of its allowed-tools "
|
||||
f"{list(skill.allowed_tools)} are configured on this agent."
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _warn(self, message: str) -> None:
|
||||
self._discovery_warnings.append(message)
|
||||
if self.warning_reporter is not None:
|
||||
self.warning_reporter(message)
|
||||
@ -33,6 +33,7 @@ from runtime.node.agent.memory.memory_base import (
|
||||
)
|
||||
from runtime.node.agent import ThinkingPayload
|
||||
from runtime.node.agent import ModelProvider, ProviderRegistry, ModelResponse
|
||||
from runtime.node.agent.skills import AgentSkillManager
|
||||
from tenacity import Retrying, retry_if_exception, stop_after_attempt, wait_random_exponential
|
||||
|
||||
|
||||
@ -70,16 +71,18 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
input_payload = self._build_thinking_payload_from_inputs(inputs, input_data)
|
||||
memory_query_snapshot = self._build_memory_query_snapshot(inputs, input_data)
|
||||
input_mode = agent_config.input_mode or AgentInputMode.PROMPT
|
||||
external_tool_specs = self.tool_manager.get_tool_specs(agent_config.tooling)
|
||||
skill_manager = self._build_skill_manager(node, agent_config, external_tool_specs)
|
||||
|
||||
provider = provider_class(agent_config)
|
||||
client = provider.create_client()
|
||||
|
||||
if input_mode is AgentInputMode.PROMPT:
|
||||
conversation = self._prepare_prompt_messages(node, input_data)
|
||||
conversation = self._prepare_prompt_messages(node, input_data, skill_manager)
|
||||
else:
|
||||
conversation = self._prepare_message_conversation(node, inputs)
|
||||
conversation = self._prepare_message_conversation(node, inputs, skill_manager)
|
||||
call_options = self._prepare_call_options(node)
|
||||
tool_specs = self.tool_manager.get_tool_specs(agent_config.tooling)
|
||||
tool_specs = self._merge_skill_tool_specs(external_tool_specs, skill_manager)
|
||||
|
||||
agent_invoker = self._build_agent_invoker(
|
||||
provider,
|
||||
@ -129,6 +132,7 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
call_options,
|
||||
response_obj,
|
||||
tool_specs,
|
||||
skill_manager,
|
||||
)
|
||||
else:
|
||||
response_message = response_obj.message
|
||||
@ -172,12 +176,18 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
finally:
|
||||
self._current_node_id = None
|
||||
|
||||
def _prepare_prompt_messages(self, node: Node, input_data: str) -> List[Message]:
|
||||
def _prepare_prompt_messages(
|
||||
self,
|
||||
node: Node,
|
||||
input_data: str,
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> List[Message]:
|
||||
"""Prepare the prompt-style message sequence."""
|
||||
messages: List[Message] = []
|
||||
|
||||
if node.role:
|
||||
messages.append(Message(role=MessageRole.SYSTEM, content=node.role))
|
||||
system_prompt = self._build_system_prompt(node, skill_manager)
|
||||
if system_prompt:
|
||||
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
|
||||
|
||||
try:
|
||||
if isinstance(input_data, str):
|
||||
@ -191,11 +201,17 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
messages.append(Message(role=MessageRole.USER, content=clean_input))
|
||||
return messages
|
||||
|
||||
def _prepare_message_conversation(self, node: Node, inputs: List[Message]) -> List[Message]:
|
||||
def _prepare_message_conversation(
|
||||
self,
|
||||
node: Node,
|
||||
inputs: List[Message],
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> List[Message]:
|
||||
messages: List[Message] = []
|
||||
|
||||
if node.role:
|
||||
messages.append(Message(role=MessageRole.SYSTEM, content=node.role))
|
||||
system_prompt = self._build_system_prompt(node, skill_manager)
|
||||
if system_prompt:
|
||||
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
|
||||
|
||||
normalized_inputs = self._coerce_inputs_to_messages(inputs)
|
||||
if normalized_inputs:
|
||||
@ -220,6 +236,76 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
# call_options.setdefault("max_tokens", 4096)
|
||||
return call_options
|
||||
|
||||
def _build_skill_manager(
|
||||
self,
|
||||
node: Node,
|
||||
agent_config: AgentConfig,
|
||||
external_tool_specs: List[ToolSpec],
|
||||
) -> AgentSkillManager | None:
|
||||
skills_config = agent_config.skills
|
||||
if not skills_config or not skills_config.enabled:
|
||||
return None
|
||||
|
||||
manager = AgentSkillManager(
|
||||
allow=skills_config.allow,
|
||||
available_tool_names=[spec.name for spec in external_tool_specs],
|
||||
warning_reporter=lambda message: self.log_manager.warning(message, node_id=node.id),
|
||||
)
|
||||
return manager
|
||||
|
||||
def _build_system_prompt(self, node: Node, skill_manager: AgentSkillManager | None) -> str | None:
|
||||
parts: List[str] = []
|
||||
if node.role:
|
||||
parts.append(node.role)
|
||||
|
||||
if skill_manager is not None:
|
||||
skills_xml = skill_manager.build_available_skills_xml()
|
||||
if skills_xml:
|
||||
parts.append(
|
||||
"\n".join(
|
||||
[
|
||||
"You have access to Agent Skills.",
|
||||
"Use `activate_skill` to load the full SKILL.md instructions for a relevant skill before following it.",
|
||||
"Use `read_skill_file` to read supporting files from that skill directory when the instructions reference them.",
|
||||
"Do not assume a skill's contents until you load it.",
|
||||
skills_xml,
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
warning_lines = skill_manager.discovery_warnings()
|
||||
warning_text = "\n".join(f"- {warning}" for warning in warning_lines[:5])
|
||||
parts.append(
|
||||
"\n".join(
|
||||
[
|
||||
"Agent Skills are enabled for this node, but no compatible skills are currently available.",
|
||||
"Do not claim to use or load any skill unless it appears in <available_skills>.",
|
||||
warning_text,
|
||||
]
|
||||
).strip()
|
||||
)
|
||||
|
||||
if not parts:
|
||||
return None
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
def _merge_skill_tool_specs(
|
||||
self,
|
||||
tool_specs: List[ToolSpec],
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> List[ToolSpec]:
|
||||
if skill_manager is None:
|
||||
return tool_specs
|
||||
|
||||
merged = list(tool_specs)
|
||||
existing_names = {spec.name for spec in merged}
|
||||
for spec in skill_manager.build_tool_specs():
|
||||
if spec.name in existing_names:
|
||||
raise ValueError(f"Tool name '{spec.name}' conflicts with a built-in skill tool")
|
||||
existing_names.add(spec.name)
|
||||
merged.append(spec)
|
||||
return merged
|
||||
|
||||
def _build_agent_invoker(
|
||||
self,
|
||||
provider: ModelProvider,
|
||||
@ -479,6 +565,7 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
call_options: Dict[str, Any],
|
||||
initial_response: ModelResponse,
|
||||
tool_specs: List[ToolSpec],
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> Message:
|
||||
"""Handle tool calls until completion or until the loop limit is reached."""
|
||||
assistant_message = initial_response.message
|
||||
@ -503,7 +590,12 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
|
||||
iteration += 1
|
||||
|
||||
tool_call_messages, tool_events = self._execute_tool_batch(node, assistant_message.tool_calls, tool_specs)
|
||||
tool_call_messages, tool_events = self._execute_tool_batch(
|
||||
node,
|
||||
assistant_message.tool_calls,
|
||||
tool_specs,
|
||||
skill_manager,
|
||||
)
|
||||
conversation.extend(tool_call_messages)
|
||||
timeline.extend(tool_events)
|
||||
trace_messages.extend(self._clone_with_source(msg, node.id) for msg in tool_call_messages)
|
||||
@ -524,10 +616,11 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
node: Node,
|
||||
tool_calls: List[ToolCallPayload],
|
||||
tool_specs: List[ToolSpec],
|
||||
) -> tuple[List[Message], List[FunctionCallOutputEvent]]:
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> tuple[List[Message], List[Any]]:
|
||||
"""Execute a batch of tool calls and return conversation + timeline events."""
|
||||
messages: List[Message] = []
|
||||
events: List[FunctionCallOutputEvent] = []
|
||||
events: List[Any] = []
|
||||
model = node.as_config(AgentConfig)
|
||||
|
||||
# Build map for fast lookup
|
||||
@ -556,6 +649,98 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
tool_config = configs[idx]
|
||||
# Use original name if prefixed
|
||||
execution_name = spec.metadata.get("original_name", tool_name)
|
||||
|
||||
if spec and spec.metadata.get("source") == "agent_skill_internal":
|
||||
try:
|
||||
self.log_manager.record_tool_call(
|
||||
node.id,
|
||||
tool_name,
|
||||
None,
|
||||
None,
|
||||
{"arguments": arguments},
|
||||
CallStage.BEFORE,
|
||||
)
|
||||
with self.log_manager.tool_timer(node.id, tool_name):
|
||||
result = self._execute_skill_tool(tool_name, arguments, skill_manager)
|
||||
|
||||
tool_message = self._build_tool_message(
|
||||
result,
|
||||
tool_call,
|
||||
node_id=node.id,
|
||||
tool_name=tool_name,
|
||||
)
|
||||
events.append(self._build_function_call_output_event(tool_call, result))
|
||||
system_message = self._build_skill_followup_message(tool_name, result, node.id)
|
||||
if system_message is not None:
|
||||
messages.append(system_message)
|
||||
events.append(system_message)
|
||||
self.log_manager.record_tool_call(
|
||||
node.id,
|
||||
tool_name,
|
||||
True,
|
||||
self._serialize_tool_result(result),
|
||||
{"arguments": arguments},
|
||||
CallStage.AFTER,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.log_manager.record_tool_call(
|
||||
node.id,
|
||||
tool_name,
|
||||
False,
|
||||
None,
|
||||
{"error": str(exc), "arguments": arguments},
|
||||
CallStage.AFTER,
|
||||
)
|
||||
tool_message = Message(
|
||||
role=MessageRole.TOOL,
|
||||
content=f"Tool {tool_name} error: {exc}",
|
||||
tool_call_id=tool_call.id,
|
||||
metadata={"tool_name": tool_name, "source": node.id},
|
||||
)
|
||||
events.append(
|
||||
FunctionCallOutputEvent(
|
||||
call_id=tool_call.id or tool_call.function_name or "tool_call",
|
||||
function_name=tool_call.function_name,
|
||||
output_text=f"error: {exc}",
|
||||
)
|
||||
)
|
||||
|
||||
messages.append(tool_message)
|
||||
continue
|
||||
|
||||
active_skill = skill_manager.active_skill() if skill_manager is not None else None
|
||||
if (
|
||||
active_skill is not None
|
||||
and active_skill.allowed_tools
|
||||
and execution_name not in active_skill.allowed_tools
|
||||
):
|
||||
error_msg = (
|
||||
f"Tool '{tool_name}' is not allowed by active skill "
|
||||
f"'{active_skill.name}'. Allowed tools: {list(active_skill.allowed_tools)}"
|
||||
)
|
||||
self.log_manager.record_tool_call(
|
||||
node.id,
|
||||
tool_name,
|
||||
False,
|
||||
None,
|
||||
{"error": error_msg, "arguments": arguments},
|
||||
CallStage.AFTER,
|
||||
)
|
||||
tool_message = Message(
|
||||
role=MessageRole.TOOL,
|
||||
content=f"Error: {error_msg}",
|
||||
tool_call_id=tool_call.id,
|
||||
metadata={"tool_name": tool_name, "source": node.id},
|
||||
)
|
||||
events.append(
|
||||
FunctionCallOutputEvent(
|
||||
call_id=tool_call.id or tool_call.function_name or "tool_call",
|
||||
function_name=tool_call.function_name,
|
||||
output_text=f"error: {error_msg}",
|
||||
)
|
||||
)
|
||||
messages.append(tool_message)
|
||||
continue
|
||||
|
||||
if not tool_config:
|
||||
# Fallback check: if we have 1 config, maybe it's that one?
|
||||
@ -662,6 +847,61 @@ class AgentNodeExecutor(NodeExecutor):
|
||||
|
||||
return messages, events
|
||||
|
||||
def _build_skill_followup_message(
|
||||
self,
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
node_id: str,
|
||||
) -> Message | None:
|
||||
if tool_name != "activate_skill" or not isinstance(result, dict):
|
||||
return None
|
||||
|
||||
instructions = result.get("instructions")
|
||||
skill_name = result.get("skill_name", "unknown-skill")
|
||||
allowed_tools = result.get("allowed_tools")
|
||||
if not isinstance(instructions, str) or not instructions.strip():
|
||||
return None
|
||||
|
||||
tool_constraint = ""
|
||||
if isinstance(allowed_tools, list) and allowed_tools:
|
||||
tool_constraint = f"\n\nOnly use these external tools while this skill is active: {allowed_tools}"
|
||||
|
||||
return Message(
|
||||
role=MessageRole.SYSTEM,
|
||||
content=(
|
||||
f"Activated Agent Skill `{skill_name}`. "
|
||||
"Follow its instructions for the current task until they are completed or no longer relevant.\n\n"
|
||||
f"{instructions}{tool_constraint}"
|
||||
),
|
||||
metadata={"source": node_id, "skill_name": skill_name, "skill_activation": True},
|
||||
)
|
||||
|
||||
def _execute_skill_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
skill_manager: AgentSkillManager | None,
|
||||
) -> Dict[str, Any]:
|
||||
if skill_manager is None:
|
||||
raise ValueError("Agent Skills are not enabled for this node")
|
||||
|
||||
if tool_name == "activate_skill":
|
||||
skill_name = str(arguments.get("skill_name", "")).strip()
|
||||
if not skill_name:
|
||||
raise ValueError("skill_name is required")
|
||||
return skill_manager.activate_skill(skill_name)
|
||||
|
||||
if tool_name == "read_skill_file":
|
||||
skill_name = str(arguments.get("skill_name", "")).strip()
|
||||
relative_path = str(arguments.get("relative_path", "")).strip()
|
||||
if not skill_name:
|
||||
raise ValueError("skill_name is required")
|
||||
if not relative_path:
|
||||
raise ValueError("relative_path is required")
|
||||
return skill_manager.read_skill_file(skill_name, relative_path)
|
||||
|
||||
raise ValueError(f"Unsupported skill tool '{tool_name}'")
|
||||
|
||||
def _build_function_call_output_event(
|
||||
self,
|
||||
tool_call: ToolCallPayload,
|
||||
|
||||
39
yaml_instance/skills.yaml
Normal file
39
yaml_instance/skills.yaml
Normal file
@ -0,0 +1,39 @@
|
||||
graph:
|
||||
id: skills
|
||||
description: Workflow to demonstrate skills usage
|
||||
initial_instruction: Give the agent an instruction to explicitly use code to generate a Fibonacci sequence, sum numbers, or something else that is better done with code than LLM generation.
|
||||
log_level: DEBUG
|
||||
is_majority_voting: false
|
||||
nodes:
|
||||
- id: Qwerty
|
||||
type: agent
|
||||
config:
|
||||
name: gpt-4o
|
||||
provider: openai
|
||||
role: Use any available tools or skills - notify if none are available
|
||||
base_url: ${BASE_URL}
|
||||
api_key: ${API_KEY}
|
||||
params: {}
|
||||
tooling:
|
||||
- type: function
|
||||
config:
|
||||
tools:
|
||||
- name: code_executor:All
|
||||
timeout: null
|
||||
prefix: ''
|
||||
thinking: null
|
||||
memories: []
|
||||
skills:
|
||||
enabled: true
|
||||
allow:
|
||||
- name: python-scratchpad
|
||||
- name: rest-api-caller
|
||||
retry: null
|
||||
description: ''
|
||||
context_window: 0
|
||||
log_output: true
|
||||
edges: []
|
||||
memory: []
|
||||
start:
|
||||
- Qwerty
|
||||
end: []
|
||||
Loading…
x
Reference in New Issue
Block a user