feat: advance main agent runs from homepage handoff

This commit is contained in:
kris
2026-03-29 18:37:57 +08:00
parent 30e37e5ce1
commit d4be3a2ce1
4 changed files with 229 additions and 9 deletions

View File

@@ -2682,6 +2682,88 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
)
return bool(row)
def _complete_agent_run_for_read(row: dict[str, Any]) -> dict[str, Any]:
current_status = str(row.get("run_status") or "")
run_id = str(row.get("id") or "")
if not run_id:
return row
if current_status == "queued" and not _has_other_active_runs(
account_id=str(row.get("user_id") or ""),
project_id=str(row.get("project_id") or ""),
run_id=run_id,
):
timestamp = now()
started_at = str(row.get("started_at") or timestamp)
legacy.db.execute(
"""
UPDATE agent_runs
SET run_status = 'running', status_summary = ?, updated_at = ?, started_at = ?
WHERE id = ?
""",
("主 Agent 正在执行", timestamp, started_at, run_id),
)
_log_agent_run_event(
run_id,
event_type="run.started",
summary="主 Agent 已开始执行",
details={"run_status": "running"},
)
refreshed = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (run_id,))
if refreshed is not None:
row = refreshed
current_status = "running"
if current_status != "running" or str(row.get("finished_at") or "").strip():
return row
timestamp = now()
plan = _parse_json(row.get("plan_json"), {})
steps = [str(item).strip() for item in list(plan.get("steps") or []) if str(item).strip()]
if not steps:
steps = ["读取当前项目上下文", "结合治理层生成执行计划", "收口为可执行建议"]
summary_text = str(plan.get("summary") or row.get("summary") or "").strip() or "主 Agent 已根据当前计划完成第一版执行收口。"
execution_summary = f"已完成「{str(plan.get('goal') or row.get('title') or '主 Agent 任务').strip() or '主 Agent 任务'}」的首轮执行建议。"
result_payload = {
"result_kind": "main_agent_plan",
"goal": str(plan.get("goal") or row.get("title") or "主 Agent 任务").strip() or "主 Agent 任务",
"summary_text": summary_text,
"execution_summary": execution_summary,
"next_steps": steps,
"intent_key": str(plan.get("intent_key") or row.get("intent_key") or "custom").strip() or "custom",
"platform": str(plan.get("platform") or row.get("platform") or "").strip(),
"platform_scope": str(plan.get("platform_scope") or row.get("platform_scope") or "single_platform").strip() or "single_platform",
"active_admin_override_notice": _parse_json(row.get("active_admin_override_notice_json"), {}),
}
_log_agent_run_event(
run_id,
event_type="run.progress",
summary="主 Agent 已完成首轮分析,正在收口执行建议",
details={"completed_steps": len(steps), "total_steps": len(steps)},
)
legacy.db.execute(
"""
UPDATE agent_runs
SET run_status = 'done',
status_summary = ?,
result_json = ?,
needs_user_input = 0,
blocked_reason = '',
updated_at = ?,
finished_at = ?
WHERE id = ?
""",
(execution_summary, _dump(result_payload), timestamp, timestamp, run_id),
)
_log_agent_run_event(
run_id,
event_type="run.done",
summary=execution_summary,
details={"result_kind": "main_agent_plan", "status_summary": execution_summary},
)
refreshed = legacy.db.fetch_one("SELECT * FROM agent_runs WHERE id = ?", (run_id,))
return refreshed or row
def _deterministic_intent(message: str, platform_hint: str, account: dict[str, Any]) -> dict[str, Any]:
text = message.strip()
lowered = text.lower()
@@ -4593,6 +4675,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
account: dict[str, Any] = Depends(legacy.require_approved),
) -> dict[str, Any]:
row = _load_owned_agent_run(run_id, account)
row = _complete_agent_run_for_read(row)
return _agent_run_payload(row)
@app.get("/v2/oneliner/runs/{run_id}/events")
@@ -4601,6 +4684,7 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None:
account: dict[str, Any] = Depends(legacy.require_approved),
) -> dict[str, Any]:
row = _load_owned_agent_run(run_id, account)
row = _complete_agent_run_for_read(row)
items = _list_agent_run_events(row["id"])
return {"run": _agent_run_payload(row, include_events=False), "items": items, "count": len(items)}

View File

@@ -248,6 +248,48 @@ class MainAgentGovernanceTests(unittest.TestCase):
self.assertIn("run.confirmed", event_types)
self.assertTrue("run.queued" in event_types or "run.started" in event_types)
def test_running_agent_run_detail_advances_to_progress_and_done(self) -> None:
create = self.client.post(
"/v2/oneliner/runs",
headers=self.ctx["member_headers"],
json={
"project_id": self.ctx["project_id"],
"source_screen": "dashboard",
"source_action_key": "homepage-primary-action",
"title": "安排今日动作",
"summary": "让主 Agent 给出执行收口",
"intent_key": "custom",
"platform": "douyin",
"platform_scope": "single_platform",
"plan_request": {
"goal": "安排今日动作",
"steps": ["读取当前项目上下文", "给出执行建议", "输出下一步"],
},
},
)
self.assertEqual(create.status_code, 200, create.text)
run_id = create.json()["id"]
confirm = self.client.post(
f"/v2/oneliner/runs/{run_id}/confirm",
headers=self.ctx["member_headers"],
json={"reason": "user confirmed"},
)
self.assertEqual(confirm.status_code, 200, confirm.text)
detail = self.client.get(
f"/v2/oneliner/runs/{run_id}",
headers=self.ctx["member_headers"],
)
self.assertEqual(detail.status_code, 200, detail.text)
payload = detail.json()
self.assertEqual(payload["run_status"], "done")
self.assertTrue(payload["finished_at"])
self.assertEqual(payload["result"]["result_kind"], "main_agent_plan")
event_types = [item["event_type"] for item in payload["events"]]
self.assertIn("run.progress", event_types)
self.assertIn("run.done", event_types)
def test_effective_policy_merges_system_user_global_and_platform_layers(self) -> None:
system_response = self.client.put(
"/v2/admin/oneliner/governance/system/main-agent",

View File

@@ -28,6 +28,36 @@
`;
}
function createHandoffMetadata(config = {}) {
const steps = safeArray(config.planSteps).map((item) => String(item || "").trim()).filter(Boolean);
return {
sourceScreen: config.sourceScreen || "dashboard",
sourceActionKey: config.sourceActionKey || "homepage-action",
intentKey: config.intentKey || "custom",
title: config.title || "",
goal: config.goal || config.title || "",
summary: config.summary || "",
platform: config.platform || "",
platformScope: config.platformScope || "single_platform",
planSteps: steps.length ? steps : ["读取当前项目上下文", "结合当前动作生成执行计划", "等待确认后执行"]
};
}
function buildHandoffAttrs(config, escapeHtml) {
const metadata = createHandoffMetadata(config);
return [
`data-source-screen="${escapeHtml(metadata.sourceScreen)}"`,
`data-source-action-key="${escapeHtml(metadata.sourceActionKey)}"`,
`data-intent-key="${escapeHtml(metadata.intentKey)}"`,
`data-title="${escapeHtml(metadata.title)}"`,
`data-goal="${escapeHtml(metadata.goal)}"`,
`data-summary="${escapeHtml(metadata.summary)}"`,
`data-platform-scope="${escapeHtml(metadata.platformScope)}"`,
`data-plan-steps="${escapeHtml(JSON.stringify(metadata.planSteps))}"`,
metadata.platform ? `data-platform="${escapeHtml(metadata.platform)}"` : ""
].filter(Boolean);
}
function createDashboardHomeModel(raw) {
const trackedAccountsCount = Number(raw?.trackedAccountsCount || 0);
const assistantCount = Number(raw?.assistantCount || 0);
@@ -44,7 +74,11 @@
badges: ["最优先", "项目入口"],
goAction: "goto-intake",
goLabel: "去项目",
agentLabel: "交给主 Agent"
agentLabel: "交给主 Agent",
sourceActionKey: "homepage-primary-action",
intentKey: "create_project",
platformScope: "all_platforms",
planSteps: ["读取当前工作区项目列表", "确认需要切换还是新建项目", "给出主流程推进建议"]
});
} else if (trackedAccountsCount > 0) {
actions.push({
@@ -53,7 +87,11 @@
badges: ["最优先", "预计 10 分钟判断", "关联:重点账号"],
goAction: "goto-discovery",
goLabel: "去找对标",
agentLabel: "交给主 Agent"
agentLabel: "交给主 Agent",
sourceActionKey: "homepage-primary-action",
intentKey: "analyze_top_videos",
platform: "douyin",
planSteps: ["读取当前项目上下文", "检查重点账号最近变化", "补充高分作品分析结论"]
});
} else if (assistantCount <= 0) {
actions.push({
@@ -62,7 +100,10 @@
badges: ["最优先", "Agent 缺失"],
goAction: "goto-playbook",
goLabel: "去创建",
agentLabel: "交给主 Agent"
agentLabel: "交给主 Agent",
sourceActionKey: "homepage-primary-action",
intentKey: "create_assistant",
planSteps: ["读取当前项目职责", "确认首个 Agent 角色", "给出创建与配置建议"]
});
} else {
actions.push({
@@ -71,7 +112,10 @@
badges: ["默认推进", "主流程"],
goAction: "goto-production",
goLabel: "去处理",
agentLabel: "交给主 Agent"
agentLabel: "交给主 Agent",
sourceActionKey: "homepage-primary-action",
intentKey: "custom",
planSteps: ["读取当前项目上下文", "检查对标和生产链路状态", "生成下一步执行计划"]
});
}
@@ -80,7 +124,11 @@
title: "确认一个待执行的生产计划",
reason: "素材和结论都在,只差最后确认。",
goAction: "goto-production",
goLabel: "去处理"
goLabel: "去处理",
agentLabel: "交给主 Agent",
sourceActionKey: "homepage-secondary-action-1",
intentKey: "custom",
planSteps: ["读取当前待执行生产任务", "确认素材和结论完整性", "给出执行确认建议"]
});
}
@@ -88,7 +136,11 @@
title: "更新重点账号的跟踪摘要",
reason: "有新动态,但不值得占据大块首页空间。",
goAction: "goto-tracking",
goLabel: "去处理"
goLabel: "去处理",
agentLabel: "交给主 Agent",
sourceActionKey: `homepage-secondary-action-${Math.max(actions.length, 1)}`,
intentKey: "track_account",
planSteps: ["读取重点账号近 7 天动态", "更新跟踪摘要", "给出是否继续跟进建议"]
});
while (actions.length < 3) {
@@ -96,7 +148,11 @@
title: "继续补高分对标并安排生产",
reason: "当前项目没有更多高优先动作时,保持主流程推进。",
goAction: "goto-production",
goLabel: "去处理"
goLabel: "去处理",
agentLabel: "交给主 Agent",
sourceActionKey: `homepage-secondary-action-${actions.length}`,
intentKey: "custom",
planSteps: ["读取当前项目上下文", "检查主流程空缺", "给出下一步执行建议"]
});
}
@@ -122,6 +178,17 @@
}
function renderSecondaryAction(item, index, escapeHtml) {
const handoffAttrs = buildHandoffAttrs({
sourceScreen: "dashboard",
sourceActionKey: item.sourceActionKey || `homepage-secondary-action-${index + 1}`,
intentKey: item.intentKey || "custom",
title: item.title,
goal: item.title,
summary: item.reason,
platform: item.platform,
platformScope: item.platformScope || "single_platform",
planSteps: item.planSteps
}, escapeHtml);
return `
<div class="dashboard-action-secondary">
<div class="dashboard-action-index">${index + 2}</div>
@@ -141,6 +208,12 @@
action: item.goAction || "goto-production",
tone: "secondary"
}, escapeHtml)}
${renderActionButton({
label: item.agentLabel || "交给主 Agent",
action: "handoff-to-main-agent",
tone: "secondary",
attrs: handoffAttrs
}, escapeHtml)}
</div>
</div>
`;
@@ -152,6 +225,17 @@
const contextLinks = safeArray(model?.contextLinks);
const primaryAction = model?.primaryAction || {};
const secondaryActions = safeArray(model?.secondaryActions);
const primaryHandoffAttrs = buildHandoffAttrs({
sourceScreen: "dashboard",
sourceActionKey: primaryAction.sourceActionKey || "homepage-primary-action",
intentKey: primaryAction.intentKey || "custom",
title: primaryAction.title,
goal: primaryAction.title,
summary: primaryAction.reason,
platform: primaryAction.platform,
platformScope: primaryAction.platformScope || "single_platform",
planSteps: primaryAction.planSteps
}, escapeHtml);
return `
<div class="dashboard-home">
@@ -214,8 +298,9 @@
}, escapeHtml)}
${renderActionButton({
label: primaryAction.agentLabel || "交给主 Agent",
action: "open-oneliner",
tone: "primary"
action: "handoff-to-main-agent",
tone: "primary",
attrs: primaryHandoffAttrs
}, escapeHtml)}
</div>
</div>

View File

@@ -57,6 +57,7 @@ test("homepage v6 puts actions before overview and uses 1-primary-2-secondary st
assert.match(html, /先补抖音重点对标的高分作品分析/);
assert.match(html, /确认一个待执行的生产计划/);
assert.match(html, /更新重点账号的跟踪摘要/);
assert.match(html, /data-action="handoff-to-main-agent"/);
});
test("homepage model builds one primary action, two secondary actions, and a rule fallback label", () => {
@@ -98,3 +99,11 @@ test("homepage overview uses tab buttons and does not render legacy repeated sec
assert.ok(!html.includes("当前项目推进详情"));
assert.ok(!html.includes("重点账号 / 对标</h3><div class=\"panel-subtitle\">右栏保留"));
});
test("homepage handoff buttons carry runtime plan metadata instead of only opening chat", () => {
const source = fs.readFileSync(path.join(ROOT, "assets/storyforge-dashboard-home.js"), "utf8");
assert.match(source, /handoff-to-main-agent/);
assert.match(source, /data-source-screen=/);
assert.match(source, /data-source-action-key=/);
assert.match(source, /data-plan-steps=/);
});