AochenShen99 66b9e7f212
feat: emit structured runtime metadata (follow-up#3887) (#3906)
* 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>
2026-07-04 11:27:19 +08:00

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)