fix(skills): read skill markdown as UTF-8 (#4995)

* fix(skills): read skill markdown as UTF-8

* fix(skills): complete UTF-8 handling in skill creator

* fix(skills): finish UTF-8 skill-creator I/O

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
JieZeng777 2026-09-02 17:05:36 +08:00 committed by GitHub
parent 06e54008c9
commit a5ec7f2831
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 191 additions and 31 deletions

View File

@ -228,6 +228,9 @@ These apply repo-wide; module guides own the module-specific detail.
frontend tests live in `frontend/tests/`.
- **Format before pushing** — run `make format` (backend) / `pnpm check` (frontend). Backend
CI enforces `ruff format --check`, so formatting must be clean before a push.
- **Skill text encoding** — treat `SKILL.md` and other textual skill resources as UTF-8;
Python utilities that read or write them must pass `encoding="utf-8"` rather than
relying on the platform locale.
- **Version sources must stay in lockstep** — a release version must match identically in
`backend/pyproject.toml`, `frontend/package.json`, and `deploy/helm/deer-flow/Chart.yaml`
(`version` + `appVersion`). Pushing a `v*` git tag triggers CI that runs

View File

@ -877,6 +877,8 @@ Skills are loaded progressively — only when the task needs them, not all at on
A skill directory is a package boundary: once DeerFlow finds its `SKILL.md`, nested `SKILL.md` files under that package (for example evaluation fixtures) remain supporting data and are not registered as runtime skills. Namespace directories without their own `SKILL.md` can still group nested skills.
Skill Markdown and bundled text resources use UTF-8. Skill-creator CLI and review utilities read and write text explicitly as UTF-8 so localized skills behave consistently across operating systems.
Users can explicitly activate an enabled skill for a single turn by starting the request with `/skill-name`, for example `/data-analysis analyze uploads/foo.csv`. DeerFlow loads that skill's `SKILL.md` as hidden current-turn context while leaving the base prompt limited to skill metadata. Slash activation respects disabled skills, custom-agent skill whitelists, and existing channel commands such as `/new` and `/help`.
An enabled skill's `allowed-tools` policy applies only after that skill is explicitly slash-activated or captured in the agent's active skill context after a `read_file` load. Merely enabling, advertising, or listing a skill in a custom agent or subagent `skills` allowlist does not reduce that agent's normal toolset; subagents use the same progressive discovery and activation policy as the lead agent. During a slash-activated run, that explicit skill's policy is authoritative: reading another `SKILL.md` may provide instructions but cannot widen the slash skill's tools. Without slash activation, policies from skills actually loaded into active context retain their union semantics. Once active, the policy filters both model-visible tool schemas and tool execution. Framework discovery tools (`tool_search` and `describe_skill`) remain available so an allowed deferred tool or installed skill can still be discovered, but discovery and promotion never grant permission to execute a business tool omitted from `allowed-tools`. `task` is not framework-exempt; a restrictive skill must list it explicitly to delegate to a subagent. Per-step policy decisions are internal runtime context and are removed from observable or persisted context copies. Registry failures and an active set with no remaining valid skill fail closed to framework-safe tools; individual stale paths are ignored only when another valid active skill remains. This is best-effort behavioral scoping, not a hard security boundary: loading skill instructions through another tool is not captured, and active-skill entries can be evicted from bounded context.

View File

@ -0,0 +1,149 @@
from __future__ import annotations
import ast
import importlib.util
from pathlib import Path
from types import SimpleNamespace
REPO_ROOT = Path(__file__).resolve().parents[2]
SKILL_CREATOR_ROOT = REPO_ROOT / "skills" / "public" / "skill-creator"
SCRIPTS_DIR = SKILL_CREATOR_ROOT / "scripts"
VALIDATOR_PATH = SCRIPTS_DIR / "quick_validate.py"
def _load_validator():
spec = importlib.util.spec_from_file_location("deerflow_skill_creator_quick_validate", VALIDATOR_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _load_script(name: str):
path = SCRIPTS_DIR / f"{name}.py"
spec = importlib.util.spec_from_file_location(f"deerflow_skill_creator_{name}", path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_validate_skill_reads_markdown_as_utf8(tmp_path: Path, monkeypatch) -> None:
validator = _load_validator()
skill_dir = tmp_path / "localized-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"---\nname: localized-skill\ndescription: 处理中文内容\n---\n\n# 中文技能\n",
encoding="utf-8",
)
original_read_text = Path.read_text
def require_explicit_encoding(self: Path, encoding: str | None = None, errors: str | None = None) -> str:
if self == skill_md and encoding is None:
raise UnicodeDecodeError("gbk", b"\x80", 0, 1, "illegal multibyte sequence")
return original_read_text(self, encoding=encoding, errors=errors)
monkeypatch.setattr(Path, "read_text", require_explicit_encoding)
assert validator.validate_skill(skill_dir) == (True, "Skill is valid!")
def test_validate_skill_reports_invalid_utf8_without_raising(tmp_path: Path) -> None:
validator = _load_validator()
skill_dir = tmp_path / "invalid-encoding"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_bytes(b"---\nname: invalid-encoding\ndescription: \xff\n---\n")
assert validator.validate_skill(skill_dir) == (False, "SKILL.md is not valid UTF-8")
def test_package_skill_reports_invalid_utf8_without_traceback(tmp_path: Path, monkeypatch, capsys) -> None:
monkeypatch.syspath_prepend(str(SKILL_CREATOR_ROOT))
packager = _load_script("package_skill")
skill_dir = tmp_path / "invalid-encoding"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_bytes(b"---\nname: invalid-encoding\ndescription: \xff\n---\n")
output_dir = tmp_path / "dist"
assert packager.package_skill(skill_dir, output_dir) is None
assert "Validation failed: SKILL.md is not valid UTF-8" in capsys.readouterr().out
assert not list(tmp_path.rglob("*.skill"))
def test_shared_skill_parser_reads_markdown_as_utf8(tmp_path: Path, monkeypatch) -> None:
utils = _load_script("utils")
skill_dir = tmp_path / "localized-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text(
"---\nname: localized-skill\ndescription: 处理中文内容\n---\n\n# 中文技能\n",
encoding="utf-8",
)
original_read_text = Path.read_text
def require_explicit_encoding(self: Path, encoding: str | None = None, errors: str | None = None) -> str:
if self == skill_md and encoding is None:
raise UnicodeDecodeError("gbk", b"\x80", 0, 1, "illegal multibyte sequence")
return original_read_text(self, encoding=encoding, errors=errors)
monkeypatch.setattr(Path, "read_text", require_explicit_encoding)
assert utils.parse_skill_md(skill_dir) == (
"localized-skill",
"处理中文内容",
skill_md.read_text(encoding="utf-8"),
)
def test_improve_description_uses_utf8_for_claude_text_io(monkeypatch) -> None:
monkeypatch.syspath_prepend(str(SKILL_CREATOR_ROOT))
improve_description = _load_script("improve_description")
captured: dict[str, object] = {}
def fake_run(*args, **kwargs):
captured.update(kwargs)
return SimpleNamespace(returncode=0, stdout="改进后的描述", stderr="")
monkeypatch.setattr(improve_description.subprocess, "run", fake_run)
assert improve_description._call_claude("处理中文内容🙂", None) == "改进后的描述"
assert captured["input"] == "处理中文内容🙂"
assert captured["encoding"] == "utf-8"
def test_skill_creator_text_io_declares_utf8() -> None:
missing_encoding: list[str] = []
for script_path in sorted(SKILL_CREATOR_ROOT.rglob("*.py")):
tree = ast.parse(script_path.read_text(encoding="utf-8"), filename=str(script_path))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if isinstance(node.func, ast.Attribute) and node.func.attr in {"read_text", "write_text"}:
operation = node.func.attr
elif isinstance(node.func, ast.Name) and node.func.id == "open":
mode = node.args[1] if len(node.args) > 1 else next((keyword.value for keyword in node.keywords if keyword.arg == "mode"), None)
if isinstance(mode, ast.Constant) and isinstance(mode.value, str) and "b" in mode.value:
continue
operation = "open"
elif isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess":
keywords = {keyword.arg: keyword.value for keyword in node.keywords if keyword.arg is not None}
text_mode = any(isinstance(keywords.get(name), ast.Constant) and keywords[name].value is True for name in ("text", "universal_newlines"))
if not text_mode and "encoding" not in keywords:
continue
operation = f"subprocess.{node.func.attr}"
else:
continue
encoding = next((keyword.value for keyword in node.keywords if keyword.arg == "encoding"), None)
if not isinstance(encoding, ast.Constant) or encoding.value != "utf-8":
relative_path = script_path.relative_to(SKILL_CREATOR_ROOT)
missing_encoding.append(f"{relative_path}:{node.lineno} {operation}")
assert not missing_encoding, f"text I/O must declare encoding='utf-8': {missing_encoding}"

View File

@ -87,7 +87,7 @@ def build_run(root: Path, run_dir: Path) -> dict | None:
for candidate in [run_dir / "eval_metadata.json", run_dir.parent / "eval_metadata.json"]:
if candidate.exists():
try:
metadata = json.loads(candidate.read_text())
metadata = json.loads(candidate.read_text(encoding="utf-8"))
prompt = metadata.get("prompt", "")
eval_id = metadata.get("eval_id")
except (json.JSONDecodeError, OSError):
@ -100,7 +100,7 @@ def build_run(root: Path, run_dir: Path) -> dict | None:
for candidate in [run_dir / "transcript.md", run_dir / "outputs" / "transcript.md"]:
if candidate.exists():
try:
text = candidate.read_text()
text = candidate.read_text(encoding="utf-8")
match = re.search(r"## Eval Prompt\n\n([\s\S]*?)(?=\n##|$)", text)
if match:
prompt = match.group(1).strip()
@ -127,7 +127,7 @@ def build_run(root: Path, run_dir: Path) -> dict | None:
for candidate in [run_dir / "grading.json", run_dir.parent / "grading.json"]:
if candidate.exists():
try:
grading = json.loads(candidate.read_text())
grading = json.loads(candidate.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
if grading:
@ -149,7 +149,7 @@ def embed_file(path: Path) -> dict:
if ext in TEXT_EXTENSIONS:
try:
content = path.read_text(errors="replace")
content = path.read_text(encoding="utf-8", errors="replace")
except OSError:
content = "(Error reading file)"
return {
@ -218,7 +218,7 @@ def load_previous_iteration(workspace: Path) -> dict[str, dict]:
feedback_path = workspace / "feedback.json"
if feedback_path.exists():
try:
data = json.loads(feedback_path.read_text())
data = json.loads(feedback_path.read_text(encoding="utf-8"))
feedback_map = {
r["run_id"]: r["feedback"]
for r in data.get("reviews", [])
@ -251,7 +251,7 @@ def generate_html(
) -> str:
"""Generate the complete standalone HTML page with embedded data."""
template_path = Path(__file__).parent / "viewer.html"
template = template_path.read_text()
template = template_path.read_text(encoding="utf-8")
# Build previous_feedback and previous_outputs maps for the template
previous_feedback: dict[str, str] = {}
@ -313,7 +313,7 @@ class ReviewHandler(BaseHTTPRequestHandler):
benchmark = None
if self.benchmark_path and self.benchmark_path.exists():
try:
benchmark = json.loads(self.benchmark_path.read_text())
benchmark = json.loads(self.benchmark_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
html = generate_html(runs, self.skill_name, self.previous, benchmark)
@ -343,7 +343,7 @@ class ReviewHandler(BaseHTTPRequestHandler):
data = json.loads(body)
if not isinstance(data, dict) or "reviews" not in data:
raise ValueError("Expected JSON object with 'reviews' key")
self.feedback_path.write_text(json.dumps(data, indent=2) + "\n")
self.feedback_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
resp = b'{"ok":true}'
self.send_response(200)
except (json.JSONDecodeError, OSError, ValueError) as e:
@ -401,14 +401,14 @@ def main() -> None:
benchmark = None
if benchmark_path and benchmark_path.exists():
try:
benchmark = json.loads(benchmark_path.read_text())
benchmark = json.loads(benchmark_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
if args.static:
html = generate_html(runs, skill_name, previous, benchmark)
args.static.parent.mkdir(parents=True, exist_ok=True)
args.static.write_text(html)
args.static.write_text(html, encoding="utf-8")
print(f"\n Static viewer written to: {args.static}\n")
sys.exit(0)

View File

@ -311,12 +311,12 @@ def main():
if args.input == "-":
data = json.load(sys.stdin)
else:
data = json.loads(Path(args.input).read_text())
data = json.loads(Path(args.input).read_text(encoding="utf-8"))
html_output = generate_html(data, skill_name=args.skill_name)
if args.output:
Path(args.output).write_text(html_output)
Path(args.output).write_text(html_output, encoding="utf-8")
print(f"Report written to {args.output}", file=sys.stderr)
else:
print(html_output)

View File

@ -36,7 +36,7 @@ def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str:
cmd,
input=prompt,
capture_output=True,
text=True,
encoding="utf-8",
env=env,
timeout=timeout,
)
@ -186,7 +186,7 @@ Please respond with only the new description text in <new_description> tags, not
if log_dir:
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json"
log_file.write_text(json.dumps(transcript, indent=2))
log_file.write_text(json.dumps(transcript, indent=2), encoding="utf-8")
return description
@ -205,10 +205,10 @@ def main():
print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr)
sys.exit(1)
eval_results = json.loads(Path(args.eval_results).read_text())
eval_results = json.loads(Path(args.eval_results).read_text(encoding="utf-8"))
history = []
if args.history:
history = json.loads(Path(args.history).read_text())
history = json.loads(Path(args.history).read_text(encoding="utf-8"))
name, _, content = parse_skill_md(skill_path)
current_description = eval_results["description"]

View File

@ -227,7 +227,7 @@ def init_skill(skill_name, path):
skill_md_path = skill_dir / 'SKILL.md'
try:
skill_md_path.write_text(skill_content)
skill_md_path.write_text(skill_content, encoding="utf-8")
print("✅ Created SKILL.md")
except Exception as e:
print(f"❌ Error creating SKILL.md: {e}")
@ -239,7 +239,7 @@ def init_skill(skill_name, path):
scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name), encoding="utf-8")
example_script.chmod(0o755)
print("✅ Created scripts/example.py")
@ -247,14 +247,14 @@ def init_skill(skill_name, path):
references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title), encoding="utf-8")
print("✅ Created references/api_reference.md")
# Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET)
example_asset.write_text(EXAMPLE_ASSET, encoding="utf-8")
print("✅ Created assets/example_asset.txt")
except Exception as e:
print(f"❌ Error creating resource directories: {e}")

View File

@ -18,7 +18,10 @@ def validate_skill(skill_path):
return False, "SKILL.md not found"
# Read and validate frontmatter
content = skill_md.read_text()
try:
content = skill_md.read_text(encoding="utf-8")
except UnicodeDecodeError:
return False, "SKILL.md is not valid UTF-8"
if not content.startswith('---'):
return False, "No YAML frontmatter found"
@ -99,4 +102,4 @@ if __name__ == "__main__":
valid, message = validate_skill(sys.argv[1])
print(message)
sys.exit(0 if valid else 1)
sys.exit(0 if valid else 1)

View File

@ -65,7 +65,7 @@ def run_single_query(
f"# {skill_name}\n\n"
f"This skill handles: {skill_description}\n"
)
command_file.write_text(command_content)
command_file.write_text(command_content, encoding="utf-8")
cmd = [
"claude",
@ -269,7 +269,7 @@ def main():
parser.add_argument("--verbose", action="store_true", help="Print progress to stderr")
args = parser.parse_args()
eval_set = json.loads(Path(args.eval_set).read_text())
eval_set = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
skill_path = Path(args.skill_path)
if not (skill_path / "SKILL.md").exists():

View File

@ -148,7 +148,7 @@ def run_loop(
"test_size": len(test_set),
"history": history,
}
live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name))
live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name), encoding="utf-8")
if verbose:
def print_eval_stats(label, results, elapsed):
@ -258,7 +258,7 @@ def main():
parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here")
args = parser.parse_args()
eval_set = json.loads(Path(args.eval_set).read_text())
eval_set = json.loads(Path(args.eval_set).read_text(encoding="utf-8"))
skill_path = Path(args.skill_path)
if not (skill_path / "SKILL.md").exists():
@ -275,7 +275,10 @@ def main():
else:
live_report_path = Path(args.report)
# Open the report immediately so the user can watch
live_report_path.write_text("<html><body><h1>Starting optimization loop...</h1><meta http-equiv='refresh' content='5'></body></html>")
live_report_path.write_text(
"<html><body><h1>Starting optimization loop...</h1><meta http-equiv='refresh' content='5'></body></html>",
encoding="utf-8",
)
webbrowser.open(str(live_report_path))
else:
live_report_path = None
@ -310,15 +313,15 @@ def main():
json_output = json.dumps(output, indent=2)
print(json_output)
if results_dir:
(results_dir / "results.json").write_text(json_output)
(results_dir / "results.json").write_text(json_output, encoding="utf-8")
# Write final HTML report (without auto-refresh)
if live_report_path:
live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name))
live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name), encoding="utf-8")
print(f"\nReport: {live_report_path}", file=sys.stderr)
if results_dir and live_report_path:
(results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name))
(results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name), encoding="utf-8")
if results_dir:
print(f"Results saved to: {results_dir}", file=sys.stderr)

View File

@ -6,7 +6,7 @@ from pathlib import Path
def parse_skill_md(skill_path: Path) -> tuple[str, str, str]:
"""Parse a SKILL.md file, returning (name, description, full_content)."""
content = (skill_path / "SKILL.md").read_text()
content = (skill_path / "SKILL.md").read_text(encoding="utf-8")
lines = content.split("\n")
if lines[0].strip() != "---":