diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index 4b97862e..ddbbdc03 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -42,6 +42,10 @@ new_id, utc_now, ) +from app.general_skills.package_materialization import ( + materialize_general_skill_package, + skill_package_directory_name, +) from app.harness import ( HarnessArtifactAccessError, HarnessExecutor, @@ -53,8 +57,8 @@ register_command_tools, snapshot_harness_workspace, ) -from app.harness.execution_context import SANDBOX_WORKSPACE from app.harness.errors import HarnessExecutionError +from app.harness.execution_context import SANDBOX_WORKSPACE from app.harness.sandbox import parse_network_policy from app.knowledge.citations import knowledge_citations_from_results from app.knowledge.schema import KnowledgeSearchRequest @@ -62,9 +66,9 @@ from app.tools.tool_executor import ToolExecutor from app.tools.tool_schema import ToolCall - _INLINE_JSON_TOOL_RESULT_MAX_CHARS = 2_000 _INTERNAL_TOOL_RESULT_DIRECTORY = ".harness/tool-results" +_INTERNAL_SKILL_PACKAGE_DIRECTORY = ".harness/skill_packages" _SANDBOX_JSON_FILE_KIND = "sandbox_json_file" @@ -712,6 +716,15 @@ def _read_general_skill_package( metadata: dict[str, Any], query: str, ) -> dict[str, Any]: + package = package_from_row(skill) + package_relative_path = ( + Path(_INTERNAL_SKILL_PACKAGE_DIRECTORY) + / skill_package_directory_name(skill.slug, package.digest) + ).as_posix() + materialize_general_skill_package( + skill, + self.workspace_root / package_relative_path, + ) return { "success": True, "data": { @@ -720,11 +733,17 @@ def _read_general_skill_package( "operation": "read", "query": query, "package": _skill_package_preview(skill), + "package_workspace": { + "relative_path": package_relative_path, + "sandbox_path": _sandbox_path(package_relative_path), + "entrypoint": package.entrypoint, + }, "notice": ( - "技能包说明已加载到当前隔离 Harness transcript;" - "请由 AgentLoop 直接应用其中的 prompt、规则和示例,并按任务需要调用" - "知识库、原装 Tool、exec_command 或 typed 文件工具;Skill 本身不会" - "生成临时代码或启动第二套 runner。" + "技能包说明已加载到当前隔离 Harness transcript,且真实包文件已物化到" + "当前 TaskFrame。请直接使用 package_workspace.relative_path 下的文件," + "按任务需要调用知识库、原装 Tool、exec_command 或 typed 文件工具;" + "不要用 write_file 重写技能包中的脚本。技能本身不会生成临时代码," + "也不会启动第二套 runner。" ), }, } diff --git a/backend/app/general_skills/package_materialization.py b/backend/app/general_skills/package_materialization.py new file mode 100644 index 00000000..b56a519d --- /dev/null +++ b/backend/app/general_skills/package_materialization.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from pathlib import Path + +from app.capabilities.contracts import GeneralSkillPackage +from app.capabilities.local_general_skill import package_from_row +from app.db.models import GeneralSkill + + +def materialize_general_skill_package( + skill: GeneralSkill, + target_dir: Path, +) -> GeneralSkillPackage: + """Restore one stored GeneralSkill package below a trusted workspace path. + + The package data is stored in the database rather than as a persistent ZIP + on disk. Both the legacy Runner and Harness-native execution need the same + safe, on-disk view of that snapshot. + """ + + package = package_from_row(skill) + target_dir.mkdir(parents=True, exist_ok=True) + + metadata = skill.metadata_json if isinstance(skill.metadata_json, Mapping) else {} + directory_values = metadata.get("skill_directories", []) + if isinstance(directory_values, Sequence) and not isinstance( + directory_values, (str, bytes) + ): + for value in directory_values: + relative_path = safe_package_path(str(value or "")) + if relative_path: + (target_dir / relative_path).mkdir(parents=True, exist_ok=True) + + for file in package.files: + relative_path = safe_package_path(file.path) + if not relative_path: + continue + output_path = target_dir / relative_path + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(file.content, encoding="utf-8") + + return package + + +def safe_package_path(path: str) -> str: + """Normalize a package-relative path and reject traversal or unsafe parts.""" + + cleaned = path.replace("\\", "/").strip().strip("/") + parts = [part for part in cleaned.split("/") if part and part != "."] + if not parts or any(part == ".." for part in parts): + return "" + return "/".join(parts) + + +def skill_package_directory_name(slug: str, digest: str) -> str: + """Return a short, human-readable immutable package directory name.""" + + normalized_slug = re.sub(r"[^A-Za-z0-9_-]+", "-", slug).strip("-") + normalized_slug = normalized_slug[:80] or "general-skill" + normalized_digest = digest.removeprefix("sha256:") + short_digest = re.sub(r"[^A-Fa-f0-9]", "", normalized_digest)[:12] + if not short_digest: + raise ValueError("GeneralSkill package digest is invalid.") + return f"{normalized_slug}--{short_digest.lower()}" diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index 8aa86b36..10f85dc6 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -16,6 +16,7 @@ from app import paths from app.db.models import GeneralSkill, ModelConfig +from app.general_skills.package_materialization import materialize_general_skill_package from app.general_skills.runtime_env import ( GeneralSkillRuntimeError, ensure_runtime_python, @@ -1033,19 +1034,7 @@ def _skill_package_payload(skill: GeneralSkill, preview_limit: int = 12000) -> d def _materialize_skill_package(skill: GeneralSkill, target_dir: Path) -> None: - target_dir.mkdir(parents=True, exist_ok=True) - metadata = getattr(skill, "metadata_json", None) - directory_values = metadata.get("skill_directories", []) if isinstance(metadata, Mapping) else [] - if isinstance(directory_values, Sequence) and not isinstance(directory_values, (str, bytes)): - for value in directory_values: - relative_path = _safe_package_path(str(value or "")) - if relative_path: - (target_dir / relative_path).mkdir(parents=True, exist_ok=True) - for file in _skill_files(skill): - relative_path = _safe_package_path(str(file["path"])) - output_path = target_dir / relative_path - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(str(file.get("content") or ""), encoding="utf-8") + materialize_general_skill_package(skill, target_dir) def _safe_package_path(path: str) -> str: diff --git a/backend/app/llm/prompts/harness_agent_prompt.md b/backend/app/llm/prompts/harness_agent_prompt.md index 2cb8bc45..232228dd 100644 --- a/backend/app/llm/prompts/harness_agent_prompt.md +++ b/backend/app/llm/prompts/harness_agent_prompt.md @@ -24,8 +24,10 @@ source_user_message 是创建或最近更新该 TaskFrame 的用户原话,只 - 读取技能包后,直接把 prompt、规范、知识说明和示例作为本 TaskFrame 的执行指导, 再按需要调用知识库、HTTP/MCP/A2A Tool、exec_command 或 typed 文件工具。Skill 不会启动第二套 runner,也不得为了包装答案而生成代码。若任务本身要求创建或编辑 - 代码,使用 write_file/edit_file 等 typed 文件工具;若包内已有明确脚本,可按 - SKILL.md 指令使用 read_file 检查后,通过 exec_command 执行该既有脚本。 + 代码,使用 write_file/edit_file 等 typed 文件工具;若 GeneralSkill 结果提供 + `package_workspace.relative_path`,它是该包真实文件的当前 TaskFrame 相对目录。若包内已有 + 明确脚本,可按 SKILL.md 指令使用 read_file 检查后,通过 exec_command 执行这个目录中的 + 既有脚本,不得用 write_file 重写或复制该脚本。 - 如果 GeneralSkill 明确要求返回固定 JSON,Skill 描述的是业务结果契约,不要求 Skill 作者编写 Harness 的 `action` 字段。你仍应使用 `finish`,把业务 JSON 原样放入 `structured_result`,并在 `reply_fragment` 中给出相同 JSON 文本;不得因为对象中包含 diff --git a/backend/tests/test_harness_v2.py b/backend/tests/test_harness_v2.py index 5b423458..bf38ba7b 100644 --- a/backend/tests/test_harness_v2.py +++ b/backend/tests/test_harness_v2.py @@ -12,6 +12,7 @@ from sqlmodel import Session, SQLModel, create_engine, select from app.agents.branching import ensure_open_gallery_binding +from app.capabilities.local_general_skill import package_from_row from app.core import harness_agent as harness_agent_module from app.core import turn_planner as turn_planner_module from app.core.agent_loop import AgentLoop @@ -1958,6 +1959,7 @@ def test_general_skill_harness_tool_reads_full_package_when_requested( {"path": "SKILL.md", "content": "# Runner"}, {"path": "scripts/run.sh", "content": "echo ok"}, ], + metadata_json={"skill_directories": ["references/empty"]}, status="published", ) descriptor = CapabilityDescriptor( @@ -1996,7 +1998,18 @@ def test_general_skill_harness_tool_reads_full_package_when_requested( "scripts/run.sh", ] assert read_result["data"]["operation"] == "read" - assert "不会生成临时代码" in read_result["data"]["notice"] + assert "真实包文件已物化" in read_result["data"]["notice"] + assert "不会启动第二套 runner" in read_result["data"]["notice"] + package = package_from_row(skill) + package_workspace = read_result["data"]["package_workspace"] + assert package_workspace["relative_path"] == ( + f".harness/skill_packages/runner--{package.digest.removeprefix('sha256:')[:12]}" + ) + package_root = invoker.workspace_root / package_workspace["relative_path"] + assert (package_root / "SKILL.md").read_text(encoding="utf-8") == "# Runner" + assert (package_root / "scripts/run.sh").read_text(encoding="utf-8") == "echo ok" + assert (package_root / "references/empty").is_dir() + assert invoker.discover_artifacts() == [] def test_general_skill_harness_tool_defaults_to_read_instead_of_generating_code(