edit skill

This commit is contained in:
qnsh
2026-08-27 14:05:15 +08:00
parent 1bd4ea9c71
commit b5e602133f
3 changed files with 67 additions and 59 deletions
+2 -8
View File
@@ -35,15 +35,9 @@ The node supports text, image, and video inputs. Video is sent directly using th
### skills
The `OpenAI Text Skill Options` node loads standard local `SKILL.md` directories and connects to `OpenAI Text API` through `skill_options`. Configure Skill roots with `skills.paths`; relative paths resolve from this plugin directory, and the default is `skills/`. Skill calls are disabled unless `skills.allow_call` is explicitly set to `true`; discovery and read-only loading remain available by default.
The `OpenAI Text Skill Options` node discovers local skills organized around `SKILL.md`. Select a Skill and connect the node to `OpenAI Text API` through `skill_options`. Configure Skill locations with `skills.paths`; relative paths resolve from this plugin directory, and the default location is `skills/`. Set `skills.allow_call` to `true` to enable Skill calls.
Skills use progressive disclosure with a mandatory first load. In a new Skill session the model must read and validate the complete `SKILL.md` before final text is accepted; after that, allowlisted reference files are read only when needed. A persistent session may reuse the same loaded Skill hash, which is reported explicitly in `Skill Trace`. Files in `scripts/` are never executed, and shell, subprocess, network, and file-write capabilities are not provided.
Both protocols use the same internal `pi_skill_agent` loop and the same read-only tools. A new session exposes only `load_skill`; after loading it exposes only `read_skill_file`. Like Pi Agent, the loop omits `tool_choice` and uses the provider default; the host-side `skill_not_loaded` gate rejects final text until the initial load succeeds. `openai-responses` transports calls as custom `function_call`/`function_call_output` items, while `openai-completions` uses assistant function tool calls and `role: tool` messages. This is a local plugin protocol, not an official OpenAI Skills or shell attachment. Responses failures never fall back to Completions.
Supported reference extensions are `.md`, `.txt`, `.json`, `.yaml`, and `.yml`. Paths, file sizes, tool rounds, call counts, and disclosed bytes have fixed internal limits; the public `skills` configuration contains only `paths` and `allow_call`. In Skill mode, advanced options cannot override Skill tools, tool choice, state IDs, containers, or Skill attachments. The public Skill Options protocol remains `schema_version: 1`.
For Skill calls, `Conversation` is a complete, deterministically redacted session ledger, not a summary. Each `model_request.payload` and `model_response.response` retains the actual protocol-native structure, fields, item ordering, IDs, reasoning, messages, function calls, and provider extensions. Tool lifecycle and continuation items are recorded alongside them. The committed `provider_context` used for Session continuation is derived from the same `turn_commit` ledger event used by the UI, preventing a second divergent history. `Skill Trace` is a separate JSON audit summary containing the selected Skill name/hash, load state and source, actual tool-choice strategy, compatibility retry count, tool rounds, tool call count, successfully read relative paths, and errors. Trace never includes file contents or real absolute paths and is not added to future model context. If execution fails, ComfyUI cannot produce node outputs, so the same trace summary is appended to the raised error message.
When a Skill is selected, the model follows its instructions and reads bundled text resources as needed. Script files can be read as text but are never executed. Skill mode cannot run commands, edit or write files, or independently access the network. The target model service must support tool calls.
## Advanced usage instructions
+2 -8
View File
@@ -35,15 +35,9 @@ git clone https://github.com/ycyy/ComfyUI-YCYY-API.git
### skills
`OpenAI 文本 Skill 选项` 节点可以加载服务器本地的标准 `SKILL.md` 目录,并通过 `skill_options` 连接到 `OpenAI 文本 API`。`skills.paths` 配置 Skill 根目录;相对路径以本插件目录为基准,未配置时默认扫描 `skills/`。只有显式设置 `skills.allow_call=true` 才允许进入 Skill 调用链;文件发现和只读加载默认可用。
`OpenAI 文本 Skill 选项` 节点可以发现以 `SKILL.md` 组织的本地 Skill。选择 Skill 后,通过 `skill_options` 连接到 `OpenAI 文本 API` 节点。使用 `skills.paths` 配置 Skill 位置;相对路径以本插件目录为基准,默认位置为 `skills/`。将 `skills.allow_call` 设置为 `true` 后即可启用 Skill 调用。
Skill 使用带首次必载门的渐进式披露。新 Skill Session 中,模型必须先完整读取并校验 `SKILL.md`,否则最终文本会被拒绝;加载后才按需读取允许的 reference。持久 Session 可以复用相同 Skill hash 的已加载上下文,复用来源会明确记录在 `Skill Trace` 中。`scripts/` 永远不会执行,也不提供 shell、子进程、网络或文件写入能力。
两种协议共用同一个内部 `pi_skill_agent` 循环和同一组只读工具。新 Session 只暴露 `load_skill`,加载后只暴露 `read_skill_file`。与 Pi Agent 一致,循环省略 `tool_choice` 并使用 provider 默认策略;在首次加载成功前,宿主侧 `skill_not_loaded` gate 会拒绝最终文本。`openai-responses` 使用自定义 `function_call`/`function_call_output` Items 传输,`openai-completions` 使用 assistant function tool call 与 `role: tool` message。这是插件本地协议,不是 OpenAI 官方 Skills 或 shell attachment;Responses 失败时不会回退到 Completions。
支持的 reference 扩展名为 `.md`、`.txt`、`.json`、`.yaml` 和 `.yml`。路径、文件大小、工具轮数、调用次数和累计披露量使用固定的内部限制;公开的 `skills` 配置只包含 `paths` 与 `allow_call`。Skill 模式下,高级参数不能覆盖 Skill 工具、tool choice、状态 ID、container 或 Skill attachment。公共 Skill Options 协议版本保持为 `schema_version: 1`。
Skill 调用时,`Conversation` 是完整且确定性脱敏的 Session ledger,不是摘要。每个 `model_request.payload` 和 `model_response.response` 都保留实际协议的原始结构、字段、Item 顺序、ID、reasoning、message、function call 以及 provider 扩展字段;工具生命周期和实际 continuation Item 与它们一起记录。Session 续接使用的已提交 `provider_context` 与 UI 输出均派生自同一个 `turn_commit` ledger 事件,避免出现第二份不一致的历史。`Skill 调用记录` 是独立的 JSON 审计摘要,包含所选 Skill 名称/hash、加载状态与来源、实际 tool-choice 策略、兼容重试次数、工具轮数、工具调用次数、成功读取的相对路径和错误,但不包含文件正文或真实绝对路径,也不会加入后续模型上下文。执行失败时 ComfyUI 无法生成节点输出,因此同一份 trace 摘要会附加到异常信息中。
运行时,模型会遵循所选 Skill 的说明,并按需读取其中的文本资源。脚本文件只能作为文本读取,不会被执行。Skill 模式不能运行命令、编辑或写入文件,也不能自行访问网络。目标模型服务需要支持工具调用。
### proxy
+63 -43
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import codecs
import hashlib
import html
import json
@@ -18,11 +19,10 @@ from .request_utils import FunctionToolsRejected, ToolChoiceRejected
SKILL_OPTIONS_TYPE = "ycyy.openai_text_skill_options"
SKILL_OPTIONS_SCHEMA_VERSION = 1
REFERENCE_EXTENSIONS = {".md", ".txt", ".json", ".yaml", ".yml"}
DEFAULT_LIMITS = {
"max_skill_md_bytes": 128 * 1024,
"max_reference_file_bytes": 256 * 1024,
"max_reference_total_bytes": 1024 * 1024,
"max_reference_total_bytes": 8 * 1024 * 1024,
"max_disclosed_bytes_per_execution": 512 * 1024,
"max_tool_rounds": 8,
"max_tool_calls_per_execution": 16,
@@ -56,6 +56,32 @@ def _safe_decode(data, label):
raise ValueError(f"Skill text file is not valid UTF-8: {label}") from exc
def _read_text_resource_candidate(path, relative, max_bytes):
"""Return bounded UTF-8 text bytes, or None when the file is binary."""
try:
with path.open("rb") as handle:
data = handle.read(max_bytes + 1)
except OSError as exc:
raise ValueError(f"Unable to read Skill resource: {relative}") from exc
sample = data[:8192]
try:
codecs.getincrementaldecoder("utf-8")().decode(sample, final=False)
except UnicodeDecodeError:
return None
if b"\0" in sample:
return None
if len(data) > max_bytes:
raise ValueError(f"Resource exceeds {max_bytes} bytes: {relative}")
try:
data.decode("utf-8")
except UnicodeDecodeError:
return None
if b"\0" in data:
return None
return data
def _parse_scalar(value):
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
@@ -200,44 +226,38 @@ def _snapshot_skill(skill_root, config):
manifest = []
total = 0
references_dir = root / "references"
if references_dir.exists():
resolved_refs = references_dir.resolve()
if not _is_within(resolved_refs, root):
raise ValueError("references directory escapes its Skill directory")
try:
candidates = [
item for item in root.rglob("*")
if item.is_file()
and item != skill_file
and not any(part.startswith(".") for part in item.relative_to(root).parts)
]
except (OSError, RuntimeError) as exc:
raise ValueError("Unable to scan Skill resources") from exc
candidates.sort(key=lambda item: item.relative_to(root).as_posix().casefold())
for item in candidates:
relative = item.relative_to(root).as_posix()
try:
candidates = sorted(
(
item for item in references_dir.rglob("*")
if item.is_file() and item.suffix.lower() in REFERENCE_EXTENSIONS
),
key=lambda item: item.relative_to(root).as_posix().casefold(),
)
except OSError as exc:
raise ValueError("Unable to scan Skill references") from exc
for item in candidates:
relative = item.relative_to(root).as_posix()
try:
resolved = item.resolve()
except OSError as exc:
raise ValueError(f"Unable to resolve Skill reference: {relative}") from exc
if not _is_within(resolved, root):
raise ValueError(f"Reference escapes its Skill directory: {relative}")
try:
data = resolved.read_bytes()
except OSError as exc:
raise ValueError(f"Unable to read Skill reference: {relative}") from exc
if len(data) > config["max_reference_file_bytes"]:
raise ValueError(f"Reference exceeds {config['max_reference_file_bytes']} bytes: {relative}")
_safe_decode(data, relative)
total += len(data)
if total > config["max_reference_total_bytes"]:
raise ValueError(f"Skill references exceed {config['max_reference_total_bytes']} bytes")
manifest.append({
"path": relative,
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
})
resolved = item.resolve()
except (OSError, RuntimeError) as exc:
raise ValueError(f"Unable to resolve Skill resource: {relative}") from exc
if not _is_within(resolved, root):
raise ValueError(f"Resource escapes its Skill directory: {relative}")
data = _read_text_resource_candidate(
resolved, relative, config["max_reference_file_bytes"]
)
if data is None:
continue
total += len(data)
if total > config["max_reference_total_bytes"]:
raise ValueError(f"Skill resources exceed {config['max_reference_total_bytes']} bytes")
manifest.append({
"path": relative,
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
})
digest_source = {
"skill_md_sha256": hashlib.sha256(skill_bytes).hexdigest(),
@@ -445,13 +465,13 @@ def _function_definition(name):
if name == READ_SKILL_FILE_TOOL:
return {
"name": name,
"description": "Read one exact references/... file from the loaded Skill manifest.",
"description": "Read one exact text resource from anywhere in the loaded Skill directory.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Exact references/... path from the manifest.",
"description": "Exact relative path from the loaded Skill manifest.",
}
},
"required": ["path"],
@@ -698,7 +718,7 @@ class ReadOnlySkillRuntime:
raise SkillExecutionError("skill_snapshot_changed", str(exc)) from exc
if result.get("error"):
raise SkillExecutionError(
"pi_skill_tool_call_invalid", f"Reference is not in the manifest: {path}"
"pi_skill_tool_call_invalid", f"Resource is not in the manifest: {path}"
)
self._account(result)
self.cache[path] = result
@@ -1072,7 +1092,7 @@ class SkillExecutionRouter:
def _selected_skill_text(snapshot, already_loaded):
action = (
"The complete SKILL.md is already present in this Skill session. "
"Use it for this request and read only needed reference files."
"Use it for this request and read only needed text resources."
if already_loaded
else "Use the selected Skill for this request. Before answering, load the complete SKILL.md."
)