From 895e3f3b13fce1d022854a6f29b03c9aa2b2100a Mon Sep 17 00:00:00 2001 From: kris Date: Sat, 4 Apr 2026 05:08:15 +0800 Subject: [PATCH] feat: version platform agent profiles through main agent runs --- CHANGELOG.md | 23 + collector-service/app/database.py | 1 + collector-service/app/oneliner_features.py | 396 +++++++++++++++++- tests/test_main_agent_governance.py | 57 +++ web/storyforge-web-v4/assets/app.js | 53 ++- .../tests/workbench-pages.test.mjs | 27 ++ 6 files changed, 554 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 573c76a..b43ef77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,29 @@ - 当前基线重新验证通过: - 前端测试 `67/67` - 后端单测 `36/36` + +## 2026-04-04 + +### 平台 Agent 配置历史与回滚 + +- `平台 Agent 配置` 现在和 `OneLiner 主配置` 一样,已经支持版本历史、回滚和审计,不再只是直接编辑当前值。 +- 后端新增了: + - `GET /v2/platform-agents/{platform}/profile/versions` + - `GET /v2/platform-agents/{platform}/profile/audits` + - `POST /v2/platform-agents/{platform}/profile/rollback` +- `PUT /v2/platform-agents/{platform}/profile` 现在支持记录变更原因,并在保存时自动生成新的版本快照。 +- 前端 `平台 Agent 配置` 弹层新增当前版本摘要和变更原因,`平台 Agent 面板 / 详情` 也都新增了 `看配置历史` 入口。 + +### 平台 Agent 配置进入执行回写 + +- 主 Agent 在创建 run、重试 run、完成 run 时,都会把当前平台 Agent 配置版本号一起带入执行链。 +- 平台 Agent 的 `recent_execution` 现在会显示本轮使用的 `平台 Agent vN`,方便直接判断最近一次执行到底用了哪版平台配置。 +- run 完成态结果里的 `execution_card.platform_agent_profile` 也会携带平台 Agent 版本号、标题和摘要,悬浮主 Agent 结果卡能直接回看这轮平台配置来源。 + +### 回归护栏 + +- 后端治理测试新增了平台 Agent 配置版本链路覆盖:初始化版本、连续更新、回滚生成新版本、审计记录,以及执行完成后的 `recent_execution.platform_agent_profile_version_no` 回写。 +- 前端工作台测试新增了平台 Agent 配置历史入口、历史接口、回滚接口和结果卡版本显示的校验,避免后续再把这条链断开。 - `bash scripts/check_repo_baseline.sh` - `bash scripts/smoke_fnos_storyforge_lan.sh` diff --git a/collector-service/app/database.py b/collector-service/app/database.py index 794ee6a..45a9f65 100644 --- a/collector-service/app/database.py +++ b/collector-service/app/database.py @@ -370,6 +370,7 @@ class Database: "last_used_at": "TEXT NOT NULL DEFAULT ''", "last_intent_key": "TEXT NOT NULL DEFAULT ''", "last_oneliner_profile_version_no": "INTEGER NOT NULL DEFAULT 0", + "last_platform_profile_version_no": "INTEGER NOT NULL DEFAULT 0", "last_execution_summary": "TEXT NOT NULL DEFAULT ''", "last_source_screen": "TEXT NOT NULL DEFAULT ''", }, diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index a219a36..477b0d3 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -49,6 +49,13 @@ class PlatformAgentProfileRequest(BaseModel): notes: str = "" status: str = "active" config: dict[str, Any] = Field(default_factory=dict) + reason: str = "" + + +class PlatformAgentProfileRollbackRequest(BaseModel): + project_id: str = "" + version_id: str + reason: str = "" class AgentMemoryUpsertRequest(BaseModel): @@ -510,6 +517,45 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL ); + CREATE TABLE IF NOT EXISTS platform_agent_profile_versions ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + assistant_id TEXT NOT NULL DEFAULT '', + version_no INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL DEFAULT '', + mission TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + config_json TEXT NOT NULL DEFAULT '{}', + summary TEXT NOT NULL DEFAULT '', + reason TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'user_update', + rollback_from_version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(profile_id, version_no), + FOREIGN KEY(profile_id) REFERENCES platform_agent_profiles(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE, + FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE SET NULL, + FOREIGN KEY(assistant_id) REFERENCES assistants(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS platform_agent_profile_audit_logs ( + id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL, + version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + action_key TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(profile_id) REFERENCES platform_agent_profiles(id) ON DELETE CASCADE, + FOREIGN KEY(version_id) REFERENCES platform_agent_profile_versions(id) ON DELETE SET NULL + ); + CREATE TABLE IF NOT EXISTS agent_memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, @@ -1064,6 +1110,217 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: ) return payload + def _summarize_platform_agent_profile(row: dict[str, Any] | None) -> str: + data = row or {} + def _clip(value: str, limit: int) -> str: + text = str(value or "").strip() + if len(text) <= limit: + return text + return f"{text[: max(limit - 1, 0)].rstrip()}…" + parts: list[str] = [] + name = str(data.get("name") or "").strip() + if name: + parts.append(name) + mission = str(data.get("mission") or "").strip() + if mission: + parts.append(_clip(mission, 36)) + notes = str(data.get("notes") or "").strip() + if notes: + parts.append(f"备注 {_clip(notes, 32)}") + return ",".join(parts[:4]) + + def _platform_agent_profile_version_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + assistant = None + if row.get("assistant_id"): + assistant_row = legacy.db.fetch_one("SELECT * FROM assistants WHERE id = ?", (row["assistant_id"],)) + if assistant_row: + assistant = legacy.assistant_payload(assistant_row) + return { + "id": row["id"], + "profile_id": row.get("profile_id", ""), + "user_id": row.get("user_id", ""), + "project_id": row.get("project_id", ""), + "platform": row.get("platform", ""), + "assistant_id": row.get("assistant_id", ""), + "version_no": int(row.get("version_no") or 0), + "name": row.get("name", ""), + "mission": row.get("mission", ""), + "notes": row.get("notes", ""), + "status": row.get("status", "active"), + "config": _parse_json(row.get("config_json"), {}), + "summary": row.get("summary", ""), + "reason": row.get("reason", ""), + "source_type": row.get("source_type", ""), + "rollback_from_version_id": row.get("rollback_from_version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "assistant": assistant, + "created_at": row.get("created_at", ""), + } + + def _platform_agent_profile_audit_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + version_row = legacy.db.fetch_one("SELECT * FROM platform_agent_profile_versions WHERE id = ?", (row.get("version_id", ""),)) if row.get("version_id") else None + return { + "id": row["id"], + "profile_id": row.get("profile_id", ""), + "version_id": row.get("version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "action_key": row.get("action_key", ""), + "summary": row.get("summary", ""), + "details": _parse_json(row.get("details_json"), {}), + "version": _platform_agent_profile_version_payload(version_row), + "created_at": row.get("created_at", ""), + } + + def _list_platform_agent_profile_versions(profile_row: dict[str, Any] | None) -> list[dict[str, Any]]: + if not profile_row: + return [] + rows = legacy.db.fetch_all( + """ + SELECT * FROM platform_agent_profile_versions + WHERE profile_id = ? + ORDER BY version_no DESC, created_at DESC + """, + (profile_row["id"],), + ) + return [_platform_agent_profile_version_payload(row) for row in rows if row] + + def _list_platform_agent_profile_audits(profile_row: dict[str, Any] | None, *, limit: int = 12) -> list[dict[str, Any]]: + if not profile_row: + return [] + rows = legacy.db.fetch_all( + """ + SELECT * FROM platform_agent_profile_audit_logs + WHERE profile_id = ? + ORDER BY created_at DESC + LIMIT ? + """, + (profile_row["id"], limit), + ) + return [_platform_agent_profile_audit_payload(row) for row in rows if row] + + def _current_platform_agent_profile_version_row(profile_row: dict[str, Any] | None) -> dict[str, Any] | None: + if not profile_row: + return None + return legacy.db.fetch_one( + """ + SELECT * FROM platform_agent_profile_versions + WHERE profile_id = ? + ORDER BY version_no DESC, created_at DESC + LIMIT 1 + """, + (profile_row["id"],), + ) + + def _log_platform_agent_profile_audit( + *, + profile_id: str, + version_id: str, + actor_user_id: str, + action_key: str, + summary: str, + details: dict[str, Any] | None = None, + ) -> dict[str, Any]: + audit_id = make_id("plat_agent_audit") + legacy.db.execute( + """ + INSERT INTO platform_agent_profile_audit_logs ( + id, profile_id, version_id, actor_user_id, action_key, summary, details_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + audit_id, + profile_id, + version_id, + actor_user_id, + action_key, + summary, + _dump(details or {}), + now(), + ), + ) + row = legacy.db.fetch_one("SELECT * FROM platform_agent_profile_audit_logs WHERE id = ?", (audit_id,)) + assert row is not None + return _platform_agent_profile_audit_payload(row) + + def _create_platform_agent_profile_version( + profile_row: dict[str, Any], + *, + actor_user_id: str, + source_type: str, + reason: str, + rollback_from_version_id: str = "", + ) -> dict[str, Any]: + current = legacy.db.fetch_one( + "SELECT COALESCE(MAX(version_no), 0) AS max_version FROM platform_agent_profile_versions WHERE profile_id = ?", + (profile_row["id"],), + ) + version_no = int((current or {}).get("max_version") or 0) + 1 + version_id = make_id("plat_agent_ver") + summary = _summarize_platform_agent_profile(profile_row) + version_params = ( + version_id, + profile_row["id"], + profile_row.get("user_id", ""), + profile_row.get("project_id", ""), + profile_row.get("platform", ""), + profile_row.get("assistant_id", ""), + version_no, + profile_row.get("name", ""), + profile_row.get("mission", ""), + profile_row.get("notes", ""), + profile_row.get("status", "active"), + profile_row.get("config_json", "{}"), + summary, + reason.strip(), + source_type, + rollback_from_version_id, + actor_user_id, + now(), + ) + insert_sql = """ + INSERT INTO platform_agent_profile_versions ( + id, profile_id, user_id, project_id, platform, assistant_id, version_no, + name, mission, notes, status, config_json, summary, reason, + source_type, rollback_from_version_id, actor_user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + if profile_row.get("assistant_id"): + legacy.db.execute(insert_sql, version_params) + else: + with legacy.db.session() as conn: + conn.execute("PRAGMA foreign_keys=OFF") + conn.execute(insert_sql, version_params) + conn.execute("PRAGMA foreign_keys=ON") + row = legacy.db.fetch_one("SELECT * FROM platform_agent_profile_versions WHERE id = ?", (version_id,)) + assert row is not None + return _platform_agent_profile_version_payload(row) + + def _ensure_platform_agent_profile_version_seed(profile_row: dict[str, Any], *, actor_user_id: str = "") -> dict[str, Any]: + current_version = _current_platform_agent_profile_version_row(profile_row) + if current_version: + payload = _platform_agent_profile_version_payload(current_version) + assert payload is not None + return payload + payload = _create_platform_agent_profile_version( + profile_row, + actor_user_id=actor_user_id or profile_row.get("user_id", ""), + source_type="system_seed", + reason="初始化平台 Agent 配置", + ) + _log_platform_agent_profile_audit( + profile_id=profile_row["id"], + version_id=payload["id"], + actor_user_id=actor_user_id or profile_row.get("user_id", ""), + action_key="seed-platform-agent-profile", + summary="初始化平台 Agent 配置版本", + details={"project_id": profile_row.get("project_id", ""), "platform": profile_row.get("platform", "")}, + ) + return payload + def _oneliner_profile_runtime_snapshot(row: dict[str, Any], *, account: dict[str, Any] | None = None) -> dict[str, Any]: bundle = _oneliner_profile_bundle(row, account=account) return { @@ -1226,6 +1483,10 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: else: readiness_label = "待补全" recent_execution = None + current_version = None + if row: + _ensure_platform_agent_profile_version_seed(row, actor_user_id=account.get("id", "")) + current_version = _platform_agent_profile_version_payload(_current_platform_agent_profile_version_row(row)) if row and str(row.get("last_run_id") or "").strip(): recent_execution = { "run_id": str(row.get("last_run_id") or "").strip(), @@ -1234,6 +1495,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "intent_key": str(row.get("last_intent_key") or "").strip(), "intent_label": INTENT_LABELS.get(str(row.get("last_intent_key") or "").strip() or "custom", "主 Agent 任务"), "oneliner_profile_version_no": int(row.get("last_oneliner_profile_version_no") or 0), + "platform_agent_profile_version_no": int(row.get("last_platform_profile_version_no") or 0), "summary": str(row.get("last_execution_summary") or "").strip(), "source_screen": str(row.get("last_source_screen") or "").strip(), } @@ -1262,6 +1524,11 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "intent_key": str(latest_run_row.get("intent_key") or "").strip(), "intent_label": INTENT_LABELS.get(str(latest_run_row.get("intent_key") or "").strip() or "custom", "主 Agent 任务"), "oneliner_profile_version_no": int(latest_profile_version.get("version_no") or 0), + "platform_agent_profile_version_no": int( + (((latest_result.get("execution_card") or {}).get("platform_agent_profile") or {}).get("version_no")) + or (((latest_governance.get("platform_agent_profile") or {}).get("current_version") or {}).get("version_no")) + or 0 + ), "summary": str(latest_run_row.get("status_summary") or "").strip(), "source_screen": str(latest_run_row.get("source_screen") or "").strip(), } @@ -1285,6 +1552,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "readiness_score": readiness_score, "readiness_label": readiness_label, "assistant": assistant, + "current_version": current_version, "created_at": (row or {}).get("created_at", ""), "updated_at": (row or {}).get("updated_at", ""), } @@ -1317,6 +1585,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "recent_memory_title": str(recent_memory.get("title") or "").strip(), "recent_skill_title": str(recent_skill.get("title") or "").strip(), "recent_execution": payload.get("recent_execution") or {}, + "current_version": payload.get("current_version") or {}, } def _record_platform_agent_execution_feedback( @@ -1329,6 +1598,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: intent_key: str, source_screen: str, oneliner_profile_version_no: int, + platform_agent_profile_version_no: int, execution_summary: str, ) -> None: normalized_platform = _safe_platform(platform, fallback="") if str(platform or "").strip() else "" @@ -1338,7 +1608,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: """ UPDATE platform_agent_profiles SET last_run_id = ?, last_run_status = ?, last_used_at = ?, last_intent_key = ?, - last_oneliner_profile_version_no = ?, last_execution_summary = ?, last_source_screen = ?, updated_at = ? + last_oneliner_profile_version_no = ?, last_platform_profile_version_no = ?, last_execution_summary = ?, last_source_screen = ?, updated_at = ? WHERE user_id = ? AND project_id = ? AND platform = ? """, ( @@ -1347,6 +1617,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: now(), str(intent_key or "custom").strip() or "custom", int(oneliner_profile_version_no or 0), + int(platform_agent_profile_version_no or 0), str(execution_summary or "").strip(), str(source_screen or "").strip(), now(), @@ -3336,6 +3607,9 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "platform_label": str(platform_agent_profile.get("platform_label") or "").strip(), "name": str(platform_agent_profile.get("name") or "").strip(), "assistant_name": str(platform_agent_profile.get("assistant_name") or "").strip(), + "version_no": int(((platform_agent_profile.get("current_version") or {}).get("version_no") or 0)), + "version_title": str(((platform_agent_profile.get("current_version") or {}).get("title") or "").strip()), + "version_summary": str(((platform_agent_profile.get("current_version") or {}).get("summary") or "").strip()), "mission": str(platform_agent_profile.get("mission") or "").strip(), "status": str(platform_agent_profile.get("status") or "").strip(), "readiness_label": str(platform_agent_profile.get("readiness_label") or "").strip(), @@ -3373,6 +3647,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: intent_key=str(plan.get("intent_key") or row.get("intent_key") or "custom").strip() or "custom", source_screen=str(row.get("source_screen") or "").strip(), oneliner_profile_version_no=int(oneliner_profile_version.get("version_no") or 0), + platform_agent_profile_version_no=int(((platform_agent_profile.get("current_version") or {}).get("version_no") or 0)), execution_summary=execution_summary, ) _log_agent_run_event( @@ -4058,6 +4333,21 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: conn.execute(insert_sql, insert_params) conn.execute("PRAGMA foreign_keys=ON") row = legacy.db.fetch_one("SELECT * FROM platform_agent_profiles WHERE id = ?", (profile_id,)) + assert row is not None + version = _create_platform_agent_profile_version( + row, + actor_user_id=account["id"], + source_type="user_update", + reason=request.reason.strip() or f"更新 {legacy.platform_label(platform)} Agent 配置", + ) + _log_platform_agent_profile_audit( + profile_id=row["id"], + version_id=version["id"], + actor_user_id=account["id"], + action_key="update-platform-agent-profile", + summary=f"已更新 {legacy.platform_label(platform)} Agent 配置", + details={"project_id": project["id"], "platform": platform, "assistant_id": row.get("assistant_id", "")}, + ) return _platform_agent_payload(account, row, platform=platform, project_id=project["id"]) def _upsert_memory( @@ -5832,6 +6122,110 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: normalized_platform = _safe_platform(platform) return _upsert_platform_profile(account, normalized_platform, request) + @app.get("/v2/platform-agents/{platform}/profile/versions") + def list_platform_agent_profile_versions( + platform: str, + project_id: str | None = Query(default=None), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + normalized_platform = _safe_platform(platform) + row = legacy.db.fetch_one( + "SELECT * FROM platform_agent_profiles WHERE user_id = ? AND project_id = ? AND platform = ?", + (account["id"], project["id"], normalized_platform), + ) + if not row: + return {"items": [], "count": 0, "current_version": None} + _ensure_platform_agent_profile_version_seed(row, actor_user_id=account["id"]) + items = _list_platform_agent_profile_versions(row) + return {"items": items, "count": len(items), "current_version": items[0] if items else None} + + @app.get("/v2/platform-agents/{platform}/profile/audits") + def list_platform_agent_profile_audits( + platform: str, + project_id: str | None = Query(default=None), + limit: int = Query(default=12, ge=1, le=50), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + normalized_platform = _safe_platform(platform) + row = legacy.db.fetch_one( + "SELECT * FROM platform_agent_profiles WHERE user_id = ? AND project_id = ? AND platform = ?", + (account["id"], project["id"], normalized_platform), + ) + if not row: + return {"items": [], "count": 0} + _ensure_platform_agent_profile_version_seed(row, actor_user_id=account["id"]) + items = _list_platform_agent_profile_audits(row, limit=limit) + return {"items": items, "count": len(items)} + + @app.post("/v2/platform-agents/{platform}/profile/rollback") + def rollback_platform_agent_profile( + platform: str, + request: PlatformAgentProfileRollbackRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + normalized_platform = _safe_platform(platform) + row = legacy.db.fetch_one( + "SELECT * FROM platform_agent_profiles WHERE user_id = ? AND project_id = ? AND platform = ?", + (account["id"], project["id"], normalized_platform), + ) + if not row: + raise HTTPException(status_code=404, detail="Platform agent profile not found") + _ensure_platform_agent_profile_version_seed(row, actor_user_id=account["id"]) + target_version = legacy.db.fetch_one( + """ + SELECT * FROM platform_agent_profile_versions + WHERE id = ? AND profile_id = ? + """, + (request.version_id, row["id"]), + ) + if not target_version: + raise HTTPException(status_code=404, detail="Platform agent profile version not found") + assistant = _resolve_assistant(account, target_version.get("assistant_id", ""), project["id"]) + timestamp = now() + update_sql = """ + UPDATE platform_agent_profiles + SET assistant_id = ?, name = ?, mission = ?, notes = ?, status = ?, config_json = ?, updated_at = ? + WHERE id = ? + """ + update_params = ( + (assistant or {}).get("id", ""), + target_version.get("name", f"{legacy.platform_label(normalized_platform)} Agent"), + target_version.get("mission", ""), + target_version.get("notes", ""), + target_version.get("status", "active"), + target_version.get("config_json", "{}"), + timestamp, + row["id"], + ) + if (assistant or {}).get("id", ""): + legacy.db.execute(update_sql, update_params) + else: + with legacy.db.session() as conn: + conn.execute("PRAGMA foreign_keys=OFF") + conn.execute(update_sql, update_params) + conn.execute("PRAGMA foreign_keys=ON") + updated_row = legacy.db.fetch_one("SELECT * FROM platform_agent_profiles WHERE id = ?", (row["id"],)) + assert updated_row is not None + version = _create_platform_agent_profile_version( + updated_row, + actor_user_id=account["id"], + source_type="user_rollback", + reason=request.reason.strip() or f"回滚到版本 {target_version.get('version_no') or request.version_id}", + rollback_from_version_id=target_version["id"], + ) + _log_platform_agent_profile_audit( + profile_id=updated_row["id"], + version_id=version["id"], + actor_user_id=account["id"], + action_key="rollback-platform-agent-profile", + summary=f"已回滚 {legacy.platform_label(normalized_platform)} Agent 配置到版本 {target_version.get('version_no') or request.version_id}", + details={"project_id": project["id"], "platform": normalized_platform, "rollback_to_version_id": target_version["id"]}, + ) + return _platform_agent_payload(account, updated_row, platform=normalized_platform, project_id=project["id"]) + @app.get("/v2/platform-agents/{platform}/memories") def list_platform_memories( platform: str, diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 9b34dec..3278ff3 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -721,6 +721,16 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(profile_payload["platform"], "douyin") self.assertEqual(profile_payload["name"], "抖音增长 Agent") self.assertEqual(profile_payload["config"]["focus"], "conversion") + self.assertEqual(profile_payload["current_version"]["version_no"], 1) + first_profile_version_id = profile_payload["current_version"]["id"] + + profile_versions = self.client.get( + "/v2/platform-agents/douyin/profile/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(profile_versions.status_code, 200, profile_versions.text) + self.assertEqual(profile_versions.json()["count"], 1) memory_response = self.client.post( "/v2/platform-agents/douyin/memories", @@ -790,6 +800,44 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(review_skill.status_code, 200, review_skill.text) self.assertEqual(review_skill.json()["status"], "validated") + update_profile = self.client.put( + "/v2/platform-agents/douyin/profile", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "name": "抖音增长 Agent", + "mission": "改成优先对标高互动账号。", + "notes": "先压缩近期重点方向。", + "status": "active", + "config": {"focus": "engagement"}, + "reason": "调整当前抖音平台策略", + }, + ) + self.assertEqual(update_profile.status_code, 200, update_profile.text) + self.assertEqual(update_profile.json()["current_version"]["version_no"], 2) + + rollback_profile = self.client.post( + "/v2/platform-agents/douyin/profile/rollback", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "version_id": first_profile_version_id, + "reason": "恢复到第一版平台 Agent 配置", + }, + ) + self.assertEqual(rollback_profile.status_code, 200, rollback_profile.text) + rollback_profile_payload = rollback_profile.json() + self.assertEqual(rollback_profile_payload["current_version"]["rollback_from_version_id"], first_profile_version_id) + self.assertEqual(rollback_profile_payload["config"]["focus"], "conversion") + + profile_audits = self.client.get( + "/v2/platform-agents/douyin/profile/audits", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(profile_audits.status_code, 200, profile_audits.text) + self.assertGreaterEqual(profile_audits.json()["count"], 3) + versions = self.client.get( f"/v2/platform-agents/douyin/skills/{skill_payload['id']}/versions", headers=self.ctx["member_headers"], @@ -857,6 +905,10 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(detail_response.status_code, 200, detail_response.text) detail_payload = detail_response.json() self.assertEqual(detail_payload["run_status"], "done") + self.assertEqual( + (((detail_payload.get("result") or {}).get("execution_card") or {}).get("platform_agent_profile") or {}).get("version_no"), + rollback_profile_payload["current_version"]["version_no"], + ) refreshed_agents = self.client.get( "/v2/platform-agents", @@ -866,9 +918,14 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(refreshed_agents.status_code, 200, refreshed_agents.text) refreshed_douyin = next(item for item in refreshed_agents.json()["items"] if item["platform"] == "douyin") self.assertIn("recent_execution", refreshed_douyin) + self.assertEqual(refreshed_douyin["current_version"]["version_no"], rollback_profile_payload["current_version"]["version_no"]) self.assertEqual(refreshed_douyin["recent_execution"]["run_id"], run_payload["id"]) self.assertEqual(refreshed_douyin["recent_execution"]["intent_key"], "governance_review") self.assertGreaterEqual(refreshed_douyin["recent_execution"]["oneliner_profile_version_no"], 1) + self.assertEqual( + refreshed_douyin["recent_execution"]["platform_agent_profile_version_no"], + rollback_profile_payload["current_version"]["version_no"], + ) def test_admin_ops_routes_are_live(self) -> None: now = self.db_module.utc_now() diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 228c7cf..afa12a8 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -2019,6 +2019,7 @@ function renderOneLinerExecutionPayloadHtml(payload) { ${escapeHtml(platformAgentProfile.platform_label || platformLabel(platformAgentProfile.platform))} ${platformAgentProfile.name ? `${escapeHtml(platformAgentProfile.name)}` : ""} ${platformAgentProfile.assistant_name ? `${escapeHtml(platformAgentProfile.assistant_name)}` : ""} + ${platformAgentProfile.version_no ? `${escapeHtml(platformLabel(platformAgentProfile.platform || payload.platform || ""))} Agent v${escapeHtml(formatNumber(platformAgentProfile.version_no || 0))}` : ""} ${platformAgentProfile.readiness_label ? `= 50 ? "blue" : "orange"}">${escapeHtml(platformAgentProfile.readiness_label)} ${escapeHtml(formatNumber(platformAgentProfile.readiness_score || 0))}` : ""} @@ -4458,6 +4459,7 @@ function renderPlatformAgentPanel() { ${escapeHtml(item.recent_execution.intent_label || "主 Agent 任务")} ${escapeHtml(item.recent_execution.run_status || "done")} ${item.recent_execution.oneliner_profile_version_no ? `配置 v${escapeHtml(formatNumber(item.recent_execution.oneliner_profile_version_no))}` : ""} + ${item.recent_execution.platform_agent_profile_version_no ? `${escapeHtml(item.platform_label || platformLabel(item.platform))} Agent v${escapeHtml(formatNumber(item.recent_execution.platform_agent_profile_version_no))}` : ""} ${item.recent_execution.source_screen ? `${escapeHtml(screenLabel(item.recent_execution.source_screen) || item.recent_execution.source_screen)}` : ""}
@@ -4469,6 +4471,7 @@ function renderPlatformAgentPanel() {
查看详情 配置 + 看配置历史 补记忆 补技能
@@ -9239,11 +9242,13 @@ function openPlatformAgentProfileAction(platform) { description: "给这个平台绑定自己的执行 Agent,并补充任务目标和方法论定位。", submitLabel: "保存平台 Agent", fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(current, `当前 ${platformLabel(platform)} Agent 还没有历史版本。`) }, { name: "assistantId", label: "绑定执行 Agent", type: "select", value: current.assistant_id || assistants[0]?.value || "", options: [{ value: "", label: "先不绑定" }, ...assistants] }, { name: "name", label: "名称", value: current.name || `${platformLabel(platform)} Agent`, placeholder: "例如:快手增长 Agent" }, { name: "mission", label: "任务目标", type: "textarea", rows: 4, value: current.mission || "", placeholder: "例如:沉淀快手平台的开场结构、停留逻辑和转化方法论" }, { name: "notes", label: "补充说明", type: "textarea", rows: 4, value: current.notes || "", placeholder: "例如:优先观察短句节奏、直播切片和成交句式" }, - { name: "status", label: "状态", type: "select", value: current.status || "active", options: [{ value: "active", label: "启用" }, { value: "draft", label: "草稿" }, { value: "paused", label: "暂停" }] } + { name: "status", label: "状态", type: "select", value: current.status || "active", options: [{ value: "active", label: "启用" }, { value: "draft", label: "草稿" }, { value: "paused", label: "暂停" }] }, + { name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:调整当前平台 Agent 的拆解重点和执行方向" } ], onSubmit: async (values) => { const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(platform)}/profile`, { @@ -9255,6 +9260,7 @@ function openPlatformAgentProfileAction(platform) { mission: values.mission || "", notes: values.notes || "", status: values.status || "active", + reason: values.reason || "", config: { self_optimize: true, tenant_scoped_memory: true, @@ -9263,7 +9269,44 @@ function openPlatformAgentProfileAction(platform) { } }); appState.platformAgents = safeArray(appState.platformAgents).filter((item) => item.platform !== platform).concat(saved).sort((a, b) => String(a.platform).localeCompare(String(b.platform))); - rememberAction("平台 Agent 已保存", `已更新 ${platformLabel(platform)} Agent。`, "green", saved); + rememberAction("平台 Agent 已保存", `已更新 ${platformLabel(platform)} Agent,当前版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +async function openPlatformAgentProfileHistoryAction(platform) { + const project = requireSelectedProject(); + const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin"); + const history = await loadPolicyVersions(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/versions?project_id=${encodeURIComponent(project.id)}`); + const audits = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/audits?project_id=${encodeURIComponent(project.id)}`).catch(() => ({ items: [] })); + const current = safeArray(appState.platformAgents).find((item) => item.platform === normalizedPlatform) || {}; + const selectedVersionId = history.items[0]?.id || ""; + openActionModal({ + title: `${platformLabel(normalizedPlatform)} Agent 配置历史`, + description: "查看平台 Agent 配置版本,并从历史里选择一个版本回滚。", + submitLabel: "回滚到所选版本", + hideSubmit: !selectedVersionId, + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(current, `当前 ${platformLabel(normalizedPlatform)} Agent 还没有历史版本。`) }, + { type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `当前 ${platformLabel(normalizedPlatform)} Agent 还没有历史版本。`) }, + { type: "html", label: "审计记录", html: renderPolicyAuditsHtml(safeArray(audits.items || audits), "当前还没有审计记录。") }, + ...(selectedVersionId ? [ + { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) }, + { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台 Agent 方法论" } + ] : []) + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch(`/v2/platform-agents/${encodeURIComponent(normalizedPlatform)}/profile/rollback`, { + method: "POST", + body: { + project_id: project.id, + version_id: values.versionId || selectedVersionId, + reason: values.reason || "" + } + }); + appState.platformAgents = safeArray(appState.platformAgents).filter((item) => item.platform !== normalizedPlatform).concat(saved).sort((a, b) => String(a.platform).localeCompare(String(b.platform))); + rememberAction(`${platformLabel(normalizedPlatform)} Agent 已回滚`, `已回滚到版本 ${saved.current_version?.version_no || "所选版本"}。`, "green", saved); renderAll(); } }); @@ -9394,6 +9437,7 @@ async function openPlatformAgentDetailAction(platform) { ${escapeHtml(profile.recent_execution.intent_label || "主 Agent 任务")} ${escapeHtml(profile.recent_execution.run_status || "done")} ${profile.recent_execution.oneliner_profile_version_no ? `配置 v${escapeHtml(formatNumber(profile.recent_execution.oneliner_profile_version_no))}` : ""} + ${profile.recent_execution.platform_agent_profile_version_no ? `${escapeHtml(platformLabel(normalizedPlatform))} Agent v${escapeHtml(formatNumber(profile.recent_execution.platform_agent_profile_version_no))}` : ""} ${profile.recent_execution.source_screen ? `${escapeHtml(screenLabel(profile.recent_execution.source_screen) || profile.recent_execution.source_screen)}` : ""}
@@ -9446,6 +9490,7 @@ async function openPlatformAgentDetailAction(platform) {
运行平台自检 编辑配置 + 看配置历史 继续补记忆 继续补技能 交给主 Agent 继续 @@ -11172,6 +11217,10 @@ document.addEventListener("click", async (event) => { openPlatformAgentProfileAction(action.dataset.platform || ""); return; } + if (name === "open-platform-agent-profile-history") { + await openPlatformAgentProfileHistoryAction(action.dataset.platform || ""); + return; + } if (name === "open-platform-agent-detail") { await openPlatformAgentDetailAction(action.dataset.platform || ""); return; diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 34c46e1..183f352 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -749,6 +749,33 @@ test("main agent result rendering offers a direct route back into the recommende assert.match(lastAction, /recommended_action/); }); +test("platform agent profiles expose history, rollback, and execution version context", () => { + const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + const profileEditor = extractBetween(APP, "function openPlatformAgentProfileAction(platform)", "async function openPlatformAgentProfileHistoryAction(platform)"); + const profileHistory = extractBetween(APP, "async function openPlatformAgentProfileHistoryAction(platform)", "function openPlatformAgentMemoryAction(platform)"); + const panel = extractBetween(APP, "function renderPlatformAgentPanel()", "function renderAdminOpsPanel()"); + const detail = extractBetween(APP, "async function openPlatformAgentDetailAction(platform)", "function openPlatformSkillReviewAction(platform, skillId, accepted)"); + const execution = extractBetween(APP, "function renderOneLinerExecutionPayloadHtml(payload)", "function parseOneLinerActionPayloadValue(value)"); + + assert.match(profileEditor, /renderPolicyVersionSummary\(current,/); + assert.match(profileEditor, /name: "reason"/); + assert.match(profileEditor, /saved\.current_version\?\.version_no/); + + assert.match(profileHistory, /\/v2\/platform-agents\/\$\{encodeURIComponent\(normalizedPlatform\)\}\/profile\/versions/); + assert.match(profileHistory, /\/v2\/platform-agents\/\$\{encodeURIComponent\(normalizedPlatform\)\}\/profile\/audits/); + assert.match(profileHistory, /\/v2\/platform-agents\/\$\{encodeURIComponent\(normalizedPlatform\)\}\/profile\/rollback/); + assert.match(profileHistory, /renderPolicyVersionsHtml/); + assert.match(profileHistory, /renderPolicyAuditsHtml/); + + assert.match(panel, /open-platform-agent-profile-history/); + assert.match(detail, /open-platform-agent-profile-history/); + assert.match(panel, /platform_agent_profile_version_no/); + assert.match(detail, /platform_agent_profile_version_no/); + assert.match(execution, /platformAgentProfile\.version_no/); + assert.match(actions, /name === "open-platform-agent-profile-history"/); + assert.match(actions, /openPlatformAgentProfileHistoryAction/); +}); + test("main agent route actions keep landing context and destination screens render a notice", () => { const execution = extractBetween(APP, "function renderOneLinerExecutionPayloadHtml(payload)", "function parseOneLinerActionPayloadValue(value)"); const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {");