diff --git a/backend/app/core/agent_loop.py b/backend/app/core/agent_loop.py index d8a82599..ff40436e 100644 --- a/backend/app/core/agent_loop.py +++ b/backend/app/core/agent_loop.py @@ -1416,6 +1416,7 @@ def _mark_session_running(self, chat_session: ChatSession) -> None: chat_session.updated_at = utc_now() self.db.add(chat_session) + @staticmethod def _fallback_session_title_from_message(message: str) -> str: return ConversationProjection.fallback_session_title(message) diff --git a/backend/app/core/harness_capability_invoker.py b/backend/app/core/harness_capability_invoker.py index 44e26333..ad9b050d 100644 --- a/backend/app/core/harness_capability_invoker.py +++ b/backend/app/core/harness_capability_invoker.py @@ -53,6 +53,7 @@ HarnessToolCall, HarnessToolContext, build_file_tool_registry, + is_noise_artifact_path, open_harness_artifact, publish_changed_harness_artifacts, register_command_tools, @@ -721,13 +722,17 @@ def _invoke_general_skill( for item in (structured.get("artifact_errors") or [])[:20] if isinstance(item, dict) ] + declared = response.artifacts or [ + item + for item in (structured.get("artifacts") or [])[:20] + if isinstance(item, dict) + ] + if not declared and succeeded: + # 兜底:模型未在结果 JSON 声明产物时,自动扫描本次运行的 artifact_dir 补登, + # 产出文件不因"忘了声明"而丢失(声明式仍是首选路径) + declared = self._auto_declare_artifacts(structured) artifacts, publish_errors = self._general_skill_artifacts( - response.artifacts - or [ - item - for item in (structured.get("artifacts") or [])[:20] - if isinstance(item, dict) - ], + declared, skill_slug=skill.slug, ) artifact_errors.extend(publish_errors) @@ -774,6 +779,41 @@ def _invoke_general_skill( }, } + def _auto_declare_artifacts(self, structured: dict[str, Any]) -> list[dict[str, Any]]: + """未声明产物的兜底:扫描本次运行的 artifact_dir,把净产出文件自动登记为产物。 + + 只接受 runner 写入 structured 的工作区相对 artifact_dir(我们强制注入的, + 模型输出里的自报值已被覆盖);拒绝越出 TaskFrame 工作区的路径;缓存/中间 + 文件(点开头、__pycache__、*.tmp/*.part/*.log)不算产出;每个文件仍经 + open_harness_artifact 校验。 + """ + artifact_dir = str(structured.get("artifact_dir") or "").strip() + if not artifact_dir: + return [] + try: + workspace_root = self.workspace_root.resolve() + root = (workspace_root / artifact_dir).resolve() + if workspace_root not in root.parents or not root.is_dir(): + return [] + declared: list[dict[str, Any]] = [] + for path in sorted(root.rglob("*")): + if not path.is_file() or path.stat().st_size == 0: + continue + relative = path.relative_to(root).as_posix() + if is_noise_artifact_path(relative): + continue + declared.append({"path": f"{artifact_dir}/{relative}", "display_name": path.name}) + if len(declared) >= 20: + break + except OSError: + return [] + if declared: + self._emit_trace( + "general_skill_artifacts_auto_declared", + {"count": len(declared), "artifact_dir": artifact_dir}, + ) + return declared + def _general_skill_artifacts( self, declared: list[dict[str, Any]], diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index f3e15f81..781ae306 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -804,6 +804,10 @@ def _execute_plan( artifact_root=artifact_dir, workspace_root=workspace_root, ) + if workspace_root is not None: + # 供 invoker 在产物未声明时自动扫描补登(工作区相对路径); + # 强制覆盖:模型在输出 JSON 里自报的 artifact_dir 不可信,不得劫持扫描目录 + structured["artifact_dir"] = artifact_dir.relative_to(workspace_root).as_posix() if return_code != 0: structured.setdefault("success", False) structured.setdefault("error", f"runner exited with code {return_code}") diff --git a/backend/app/harness/__init__.py b/backend/app/harness/__init__.py index bfaff4f2..8adb98d0 100644 --- a/backend/app/harness/__init__.py +++ b/backend/app/harness/__init__.py @@ -2,6 +2,7 @@ HarnessArtifactAccessError, HarnessWorkspaceSnapshot, OpenedHarnessArtifact, + is_noise_artifact_path, normalize_harness_artifact_path, open_harness_artifact, publish_changed_harness_artifacts, @@ -51,6 +52,7 @@ "build_command_tool_registry", "build_file_tool_registry", "exec_command", + "is_noise_artifact_path", "normalize_harness_artifact_path", "open_harness_artifact", "publish_artifact", diff --git a/backend/app/harness/artifacts.py b/backend/app/harness/artifacts.py index 2e148d8d..bff8d2ee 100644 --- a/backend/app/harness/artifacts.py +++ b/backend/app/harness/artifacts.py @@ -80,6 +80,18 @@ def normalize_harness_artifact_path(raw_path: str) -> str: return PurePosixPath(*parts).as_posix() +_NOISE_ARTIFACT_SUFFIXES = (".tmp", ".part", ".partial", ".cache", ".log", ".lock") + + +def is_noise_artifact_path(relative_path: str) -> bool: + """缓存/中间产物判定(自动补登时排除):点开头文件、__pycache__、临时/日志后缀。""" + for part in relative_path.replace("\\", "/").split("/"): + if part.startswith(".") or part == "__pycache__": + return True + lower = relative_path.lower() + return lower.endswith(_NOISE_ARTIFACT_SUFFIXES) + + def open_harness_artifact( workspace_root: Path, raw_path: str, diff --git a/backend/app/llm/prompts/general_skill_repair_prompt.md b/backend/app/llm/prompts/general_skill_repair_prompt.md index 7cbd57de..76e43103 100644 --- a/backend/app/llm/prompts/general_skill_repair_prompt.md +++ b/backend/app/llm/prompts/general_skill_repair_prompt.md @@ -12,8 +12,8 @@ Markdown 可能非常混乱,不一定有 frontmatter、标题、固定字段 - 如果选择 runtime=`python`,code 必须是完整 Python 代码,并从标准输入读取 JSON,字段包括 query、skill_slug、skill_name、skill_workspace、output_dir、skill_files。 - skill_workspace 是运行时恢复出的技能文件夹绝对路径;如果技能依赖同目录的脚本、模板、数据或说明文件,应从 skill_workspace 中读取,不要假设文件在当前仓库。 - 程序必须向标准输出打印一个 JSON 对象。 -- 如果任务产生需要交付给用户下载的最终文件,必须写入 `OUTPUT_DIR`,并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对 `OUTPUT_DIR` 的路径,可附带 `display_name` 和 `description`。 -- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。修复时必须保留这一显式交付协议,禁止改成扫描目录。 +- 如果任务产生需要交付给用户下载的最终文件,必须写入 `ARTIFACT_DIR`(Python 使用 stdin 的 `artifact_dir`),并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对该目录的路径,可附带 `display_name` 和 `description`。 +- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。修复时必须保留这一显式交付协议,禁止改成扫描目录。未声明时系统会把 artifact 目录里的文件自动补登为下载产物(丢失 display_name/description),显式声明仍是首选。 - 只能使用 SKILL.md 或 package.files 明确提供的脚本、数据、命令、URL 和 API。不要自行发明第三方接口、备用 URL 或在线服务;如果文档没有足够执行来源,返回稳定失败 JSON,并设置 retryable=false。 - 如果外部网络不可用、API 返回异常、页面结构无法解析或结果不符合预期,程序也必须返回稳定 JSON,不要崩溃。 - 失败 JSON 不要只写 `Fetch failed` 这种粗粒度错误;必须尽量包含 attempted_urls、status_code、exception_type、exception_message、response_preview、parse_strategy、retryable。 diff --git a/backend/app/llm/prompts/general_skill_runner_prompt.md b/backend/app/llm/prompts/general_skill_runner_prompt.md index 8e8ce8a0..03d05e35 100644 --- a/backend/app/llm/prompts/general_skill_runner_prompt.md +++ b/backend/app/llm/prompts/general_skill_runner_prompt.md @@ -13,7 +13,7 @@ Markdown 可能非常混乱,不一定有 frontmatter、标题、固定字段 - skill_workspace 是运行时恢复出的技能文件夹绝对路径;如果技能依赖同目录的脚本、模板、数据或说明文件,应从 skill_workspace 中读取,不要假设文件在当前仓库。 - 程序必须向标准输出打印一个 JSON 对象。 - 如果任务产生需要交付给用户下载的最终文件,必须写入 `ARTIFACT_DIR`(Python 使用 stdin 的 `artifact_dir`),并在标准输出 JSON 的 `artifacts` 数组中逐个显式声明相对该目录的路径。可选字段为 `display_name` 和 `description`,例如 `{"success": true, "artifacts": [{"path": "report.xlsx", "display_name": "报销明细.xlsx"}]}`。不要输出 `/workspace/...` 或宿主机绝对路径。 -- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。未在 `artifacts` 中声明的文件不会出现在对话下载区。 +- `artifacts` 只列最终交付物;不得列入输入附件、技能包文件、缓存、日志、临时文件、runner 源码或构建中间产物。未声明时系统会把 artifact 目录里的文件自动补登为下载产物(丢失 display_name/description),显式声明仍是首选。 - 只能使用 SKILL.md 或 package.files 明确提供的脚本、数据、命令、URL 和 API。不要自行发明第三方接口、备用 URL 或在线服务;如果文档没有足够执行来源,返回稳定失败 JSON,并设置 retryable=false。 - 如果外部网络不可用、API 返回异常、页面结构无法解析或结果不符合预期,程序也必须返回稳定 JSON,不要崩溃。 - 失败 JSON 不要只写 `Fetch failed` 这种粗粒度错误;必须尽量包含 attempted_urls、status_code、exception_type、exception_message、response_preview、parse_strategy、retryable。 diff --git a/backend/tests/test_general_skill_artifact_autodeclare.py b/backend/tests/test_general_skill_artifact_autodeclare.py new file mode 100644 index 00000000..93c5e9ee --- /dev/null +++ b/backend/tests/test_general_skill_artifact_autodeclare.py @@ -0,0 +1,301 @@ +"""通用技能产物自动补登:模型未在结果 JSON 声明 artifacts 时,扫描 artifact_dir 兜底。""" + +import json +from pathlib import Path +from types import SimpleNamespace + +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine + +from app.core.capability_manifest import ( + CapabilityDescriptor, + CapabilityManifest, + general_skill_snapshot_digest, +) +from app.core.harness_capability_invoker import HarnessCapabilityInvoker +from app.db.models import ChatSession, GeneralSkill, ModelConfig, Tenant, User +from app.general_skills.runner import GeneralSkillRunner +from app.general_skills.schema import GeneralSkillExecutionPlan, GeneralSkillRunResponse + + +def _test_engine(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + with Session(engine) as db: + db.add(Tenant(id="tenant-demo", name="Demo")) + db.add( + User( + id="user-1", + tenant_id="tenant-demo", + username="user-1", + password_hash="x", + ) + ) + db.commit() + return engine + + +def _model_config() -> ModelConfig: + return ModelConfig( + id="model-test", + tenant_id="tenant-demo", + name="测试模型", + api_key_encrypted="test", + model="test-model", + ) + + +def _chat_session() -> ChatSession: + return ChatSession(id="session-1", tenant_id="tenant-demo", user_id="user-1") + + +def _skill_and_invoker(engine, tmp_path: Path, monkeypatch, *, slug: str = "ppt-maker"): + skill = GeneralSkill( + id=f"gs-{slug}", + tenant_id="tenant-demo", + slug=slug, + name="PPT 生成", + description="生成 PPT 文件", + skill_markdown="# PPT\n", + status="published", + ) + descriptor = CapabilityDescriptor( + capability_id=skill.id, + name=f"general_skill.{slug}", + kind="general_skill", + metadata={ + "slug": skill.slug, + "content_digest": general_skill_snapshot_digest(skill), + }, + ) + with Session(engine) as db: + db.add(skill) + db.commit() + invoker = HarnessCapabilityInvoker( + db, + tenant_id="tenant-demo", + session=_chat_session(), + task_frame_id="task-artifacts", + model_config=_model_config(), + manifest=CapabilityManifest(available=[descriptor]), + active_skill=None, + active_step_id=None, + agent_id=None, + ) + # 先 read 过闸(execute 前置要求) + read = invoker._invoke_general_skill( + skill.id, descriptor.metadata, {"query": "做个 PPT", "operation": "read"} + ) + assert read["success"] is True + return invoker, skill, descriptor + + +def _fake_runner_run(tmp_workspace_artifact_dir: str, payload: dict): + def fake_run(self, skill, query, model_config, user_id, **kwargs): # noqa: ANN001 + workspace_root = Path(kwargs["workspace_root"]) + artifact_dir = workspace_root / tmp_workspace_artifact_dir + artifact_dir.mkdir(parents=True, exist_ok=True) + (artifact_dir / "季度汇报.pptx").write_bytes(b"pk-ppt-bytes") + return GeneralSkillRunResponse( + skill_slug=skill.slug, + operation="execute", + execution_trace=[], + generated_code="", + stdout="", + stderr="", + structured_result=payload, + artifacts=list(payload.get("artifacts") or []), + reply="已生成", + ) + + return fake_run + + +def test_runner_records_workspace_relative_artifact_dir(tmp_path, monkeypatch) -> None: + """runner 在 structured 里回写工作区相对 artifact_dir,供 invoker 兜底扫描。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + + def fake_sandboxed_process(*_args, **kwargs): # noqa: ANN001 + return SimpleNamespace( + returncode=0, + stdout=json.dumps({"success": True}).encode(), + stderr=b"", + timed_out=False, + ) + + monkeypatch.setattr( + "app.general_skills.runner.run_sandboxed_process", fake_sandboxed_process + ) + skill = GeneralSkill( + tenant_id="tenant-demo", + slug="demo", + name="Demo", + skill_markdown="# Demo", + status="published", + ) + plan = GeneralSkillExecutionPlan(runtime="python", code="print(1)") + workspace = tmp_path / "task-ws" + _, _, structured = GeneralSkillRunner()._execute_plan( + skill, "q", plan, "user-1", [], workspace_root=workspace + ) + artifact_dir = structured.get("artifact_dir") or "" + assert artifact_dir.startswith("general_skill_") + assert artifact_dir.endswith("/artifacts") + assert not artifact_dir.startswith("/") + # 无 workspace_root(试运行路径)不带该字段 + _, _, structured_no_ws = GeneralSkillRunner()._execute_plan(skill, "q", plan, "user-1", []) + assert "artifact_dir" not in structured_no_ws + + +def test_runner_artifact_dir_overrides_model_reported_value(tmp_path, monkeypatch) -> None: + """模型在输出 JSON 自报 artifact_dir(如共享目录 attachments)不得劫持兜底扫描目录。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + + def fake_sandboxed_process(*_args, **kwargs): # noqa: ANN001 + # 模型自报共享目录,试图让兜底扫描登记别人的文件 + return SimpleNamespace( + returncode=0, + stdout=json.dumps({"success": True, "artifact_dir": "attachments"}).encode(), + stderr=b"", + timed_out=False, + ) + + monkeypatch.setattr( + "app.general_skills.runner.run_sandboxed_process", fake_sandboxed_process + ) + skill = GeneralSkill( + tenant_id="tenant-demo", + slug="demo", + name="Demo", + skill_markdown="# Demo", + status="published", + ) + plan = GeneralSkillExecutionPlan(runtime="python", code="print(1)") + workspace = tmp_path / "task-ws" + _, _, structured = GeneralSkillRunner()._execute_plan( + skill, "q", plan, "user-1", [], workspace_root=workspace + ) + # 强制覆盖为本次运行的真实产物目录,模型自报值被丢弃 + assert structured["artifact_dir"].startswith("general_skill_") + assert structured["artifact_dir"] != "attachments" + + +def test_undeclared_artifacts_auto_registered_from_artifact_dir(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = { + "success": True, + "artifact_dir": "general_skill_fake/artifacts", + # 注意:没有 artifacts 声明 + } + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is True + artifacts = result["artifacts"] + assert len(artifacts) == 1 + artifact = artifacts[0] + assert artifact["path"] == "general_skill_fake/artifacts/季度汇报.pptx" + assert artifact["display_name"] == "季度汇报.pptx" + assert artifact["size"] == len(b"pk-ppt-bytes") + assert artifact["sha256"] + assert artifact["operation"] == "general_skill.execute" + assert artifact["source"] == f"general_skill.{skill.slug}" + + +def test_declared_artifacts_skip_auto_scan(tmp_path, monkeypatch) -> None: + """显式声明存在时不触发兜底扫描(不产生重复产物)。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = { + "success": True, + "artifact_dir": "general_skill_fake/artifacts", + # runner 归一化后的声明形态:工作区相对路径 + "artifacts": [ + { + "path": "general_skill_fake/artifacts/季度汇报.pptx", + "display_name": "季度汇报.pptx", + }, + ], + } + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is True + assert len(result["artifacts"]) == 1 + # 声明路径经归一化换算为工作区相对路径 + assert result["artifacts"][0]["path"].endswith("artifacts/季度汇报.pptx") + + +def test_failed_run_does_not_auto_register(tmp_path, monkeypatch) -> None: + """失败运行不做兜底补登(半成品文件不应出现在下载区)。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, skill, descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + payload = {"success": False, "error": "boom", "artifact_dir": "general_skill_fake/artifacts"} + monkeypatch.setattr( + "app.core.harness_capability_invoker.GeneralSkillRunner.run", + _fake_runner_run("general_skill_fake/artifacts", payload), + ) + result = invoker._invoke_general_skill( + skill.id, + descriptor.metadata, + {"query": "做个 PPT", "operation": "execute"}, + ) + + assert result["success"] is False + assert result["artifacts"] == [] + + +def test_auto_declare_rejects_paths_outside_workspace(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, _skill, _descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + assert invoker._auto_declare_artifacts({"artifact_dir": "../escape"}) == [] + assert invoker._auto_declare_artifacts({"artifact_dir": ""}) == [] + assert invoker._auto_declare_artifacts({}) == [] + assert invoker._auto_declare_artifacts({"artifact_dir": "not/exist"}) == [] + + +def test_auto_declare_filters_cache_and_intermediate_files(tmp_path, monkeypatch) -> None: + """缓存/中间文件(点开头、__pycache__、tmp/part/log 后缀)不登记为产出。""" + monkeypatch.setenv("ULTRARAG_DATA_DIR", str(tmp_path / "data")) + engine = _test_engine() + with Session(engine): + invoker, _skill, _descriptor = _skill_and_invoker(engine, tmp_path, monkeypatch) + artifact_dir = invoker.workspace_root / "general_skill_x/artifacts" + (artifact_dir / "__pycache__").mkdir(parents=True) + (artifact_dir / "report.pptx").write_bytes(b"pk") + (artifact_dir / "cache.tmp").write_bytes(b"t") + (artifact_dir / "run.log").write_bytes(b"l") + (artifact_dir / ".hidden").write_bytes(b"h") + (artifact_dir / "__pycache__" / "mod.pyc").write_bytes(b"c") + declared = invoker._auto_declare_artifacts( + {"artifact_dir": "general_skill_x/artifacts"} + ) + paths = [item["path"] for item in declared] + assert paths == ["general_skill_x/artifacts/report.pptx"]