Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/app/core/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
47 changes: 41 additions & 6 deletions backend/app/core/harness_capability_invoker.py
Original file line number Diff line number Diff line change
Expand Up @@ -721,13 +721,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)
Expand Down Expand Up @@ -774,6 +778,37 @@ def _invoke_general_skill(
},
}

def _auto_declare_artifacts(self, structured: dict[str, Any]) -> list[dict[str, Any]]:
"""未声明产物的兜底:扫描本次运行的 artifact_dir,把净新增文件自动登记为产物。

只接受 runner 写入 structured 的工作区相对 artifact_dir(我们自己注入的),
拒绝越出 TaskFrame 工作区的路径;每个文件仍经 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()
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]],
Expand Down
5 changes: 5 additions & 0 deletions backend/app/general_skills/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,11 @@ def _execute_plan(
artifact_root=artifact_dir,
workspace_root=workspace_root,
)
if workspace_root is not None:
# 供 invoker 在产物未声明时自动扫描补登(工作区相对路径)
structured.setdefault(
"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}")
Expand Down
4 changes: 2 additions & 2 deletions backend/app/llm/prompts/general_skill_repair_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
2 changes: 1 addition & 1 deletion backend/app/llm/prompts/general_skill_runner_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
248 changes: 248 additions & 0 deletions backend/tests/test_general_skill_artifact_autodeclare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""通用技能产物自动补登:模型未在结果 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_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"}) == []