mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-31 18:36:00 +00:00
* feat: emit structured runtime metadata * fix: avoid subagent import cycle in replay gateway * fix: preserve legacy subtask result parsing * refactor: tighten runtime metadata contracts * fix(middleware): keep recovery hint on task exception wrapper content The structured-metadata stamp overwrote the wrapper text with the bare task-failure message, dropping the model-facing 'Continue with available context, or choose an alternative tool.' guidance that every other tool exception keeps. Append the shared hint after the formatted message. * fix(subagents): require lowercase hex for result_sha256 reader Length-only validation accepted any 64-char string; a faulty serializer or relaying wrapper could store a non-digest value in the delegation ledger. Enforce the producer's hexdigest shape with a fullmatch. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
201 lines
6.7 KiB
Python
201 lines
6.7 KiB
Python
"""Deterministic capture and rendering for loaded skill files."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import posixpath
|
|
import re
|
|
from collections.abc import Collection, Mapping
|
|
from html import escape
|
|
from typing import Any, TypedDict
|
|
|
|
import yaml
|
|
from langchain_core.messages import AIMessage, AnyMessage, ToolMessage
|
|
|
|
from deerflow.agents.thread_state import _SKILL_DESCRIPTION_MAX_CHARS, SkillEntry
|
|
|
|
_SKILL_FILE_NAME = "SKILL.md"
|
|
_FRONT_MATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
SKILL_CONTEXT_ENTRY_KEY = "skill_context_entry"
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SkillEntryMetadata(TypedDict):
|
|
path: str
|
|
description: str
|
|
|
|
|
|
def _tool_call_name(tool_call: dict[str, Any]) -> str:
|
|
name = tool_call.get("name")
|
|
if isinstance(name, str):
|
|
return name
|
|
function = tool_call.get("function")
|
|
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
|
return function["name"]
|
|
return ""
|
|
|
|
|
|
def _tool_call_id(tool_call: dict[str, Any]) -> str | None:
|
|
tool_call_id = tool_call.get("id")
|
|
return str(tool_call_id) if tool_call_id else None
|
|
|
|
|
|
def _tool_call_path(tool_call: dict[str, Any]) -> str | None:
|
|
args = tool_call.get("args")
|
|
if not isinstance(args, dict):
|
|
return None
|
|
for key in ("path", "file_path", "filepath"):
|
|
value = args.get(key)
|
|
if isinstance(value, str) and value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def _normalize_under_root(path: str, normalized_root: str) -> str | None:
|
|
normalized = posixpath.normpath(path)
|
|
if normalized == normalized_root or normalized.startswith(normalized_root + "/"):
|
|
return normalized
|
|
return None
|
|
|
|
|
|
def _is_skill_file(path: str) -> bool:
|
|
return posixpath.basename(path) == _SKILL_FILE_NAME
|
|
|
|
|
|
def _skill_name_from_path(skill_md_path: str) -> str:
|
|
"""Derive the skill name from the directory containing SKILL.md."""
|
|
return posixpath.basename(posixpath.dirname(skill_md_path))
|
|
|
|
|
|
def _parse_description(content: str) -> str:
|
|
"""Extract frontmatter description from already-read SKILL.md content."""
|
|
match = _FRONT_MATTER_RE.match(content)
|
|
if not match:
|
|
return ""
|
|
try:
|
|
metadata = yaml.safe_load(match.group(1))
|
|
except yaml.YAMLError:
|
|
return ""
|
|
if not isinstance(metadata, dict):
|
|
return ""
|
|
description = metadata.get("description")
|
|
if not isinstance(description, str):
|
|
return ""
|
|
return " ".join(description.split())[:_SKILL_DESCRIPTION_MAX_CHARS]
|
|
|
|
|
|
def _is_tool_error_text(content: str) -> bool:
|
|
return content.lstrip().startswith("Error:")
|
|
|
|
|
|
def build_skill_entry_metadata_from_read(
|
|
path: str,
|
|
content: str,
|
|
*,
|
|
skills_root: str,
|
|
) -> SkillEntryMetadata | None:
|
|
normalized_root = posixpath.normpath(skills_root.rstrip("/") or "/")
|
|
normalized_path = _normalize_under_root(path, normalized_root)
|
|
if normalized_path is None or not _is_skill_file(normalized_path) or _is_tool_error_text(content):
|
|
return None
|
|
return {
|
|
"path": normalized_path,
|
|
"description": _parse_description(content),
|
|
}
|
|
|
|
|
|
def read_skill_entry_metadata(additional_kwargs: Mapping[str, object] | None) -> SkillEntryMetadata | None:
|
|
if not additional_kwargs:
|
|
return None
|
|
raw = additional_kwargs.get(SKILL_CONTEXT_ENTRY_KEY)
|
|
if not isinstance(raw, Mapping):
|
|
return None
|
|
path = raw.get("path")
|
|
description = raw.get("description")
|
|
if not isinstance(path, str):
|
|
return None
|
|
return {
|
|
"path": path,
|
|
"description": " ".join(description.split())[:_SKILL_DESCRIPTION_MAX_CHARS] if isinstance(description, str) else "",
|
|
}
|
|
|
|
|
|
def _escape_context_text(value: object) -> str:
|
|
return escape(str(value), quote=False)
|
|
|
|
|
|
def extract_skills(
|
|
messages: list[AnyMessage],
|
|
*,
|
|
skills_root: str,
|
|
read_tool_names: Collection[str],
|
|
) -> list[SkillEntry]:
|
|
"""Enumerate skill-file reads (AI read_file call + paired ToolMessage result)."""
|
|
normalized_root = posixpath.normpath(skills_root.rstrip("/") or "/")
|
|
read_names = frozenset(read_tool_names)
|
|
|
|
skill_paths_by_id: dict[str, str] = {}
|
|
for message in messages:
|
|
if not isinstance(message, AIMessage):
|
|
continue
|
|
for tool_call in message.tool_calls or []:
|
|
if _tool_call_name(tool_call) not in read_names:
|
|
continue
|
|
tool_call_id = _tool_call_id(tool_call)
|
|
raw_path = _tool_call_path(tool_call)
|
|
path = _normalize_under_root(raw_path, normalized_root) if raw_path else None
|
|
if tool_call_id and path and _is_skill_file(path):
|
|
skill_paths_by_id[tool_call_id] = path
|
|
|
|
entries: list[SkillEntry] = []
|
|
for index, message in enumerate(messages):
|
|
if not isinstance(message, ToolMessage):
|
|
continue
|
|
if getattr(message, "status", "success") == "error":
|
|
continue
|
|
tool_call_id = str(message.tool_call_id) if message.tool_call_id else ""
|
|
expected_path = skill_paths_by_id.get(tool_call_id)
|
|
if expected_path is None:
|
|
continue
|
|
metadata = read_skill_entry_metadata(message.additional_kwargs)
|
|
if metadata is None:
|
|
content = message.content if isinstance(message.content, str) else ""
|
|
if not _is_tool_error_text(content):
|
|
logger.warning("missing skill read metadata: tool_call_id=%s path=%s", tool_call_id, expected_path)
|
|
continue
|
|
if metadata["path"] != expected_path:
|
|
logger.warning(
|
|
"mismatched skill read metadata: tool_call_id=%s expected_path=%s metadata_path=%s",
|
|
tool_call_id,
|
|
expected_path,
|
|
metadata["path"],
|
|
)
|
|
continue
|
|
entries.append(
|
|
{
|
|
"name": _skill_name_from_path(expected_path),
|
|
"path": expected_path,
|
|
"description": metadata["description"],
|
|
"loaded_at": index,
|
|
}
|
|
)
|
|
return entries
|
|
|
|
|
|
def render_skill_context(entries: list[SkillEntry]) -> str:
|
|
"""Render active-skill references as a compact reminder, not the body."""
|
|
if not entries:
|
|
return ""
|
|
|
|
lines = ["## Active skills (loaded earlier - re-read the file before applying its instructions)"]
|
|
for entry in entries:
|
|
name = _escape_context_text(entry["name"])
|
|
path = _escape_context_text(entry["path"])
|
|
raw_description = entry.get("description") or ""
|
|
if isinstance(raw_description, str):
|
|
raw_description = " ".join(raw_description.split())[:_SKILL_DESCRIPTION_MAX_CHARS]
|
|
description = _escape_context_text(raw_description)
|
|
suffix = f": {description}" if description else ""
|
|
lines.append(f"- {name}{suffix} -> {path}")
|
|
return "\n".join(lines)
|