feat: 命令台子代理链路(主任务/模型子代理/委派子任务 + 过程调用实时同步)
host:链路条目带 thread 归属;delegate 向父链路文件写 agent-start/done 标记。服务端:GET /tasks/{id}/agents 聚合父链路 + 子链路文件成代理链(状态口径:委派以结束标记为准、无标记即进行中;模型子代理无结束信号时父任务运行中为进行中、否则未知)。命令台:新增子代理链路区,随 SSE 流防抖刷新;扁平链路也渲染 agent 标记。
This commit is contained in:
1 parent
34cd0eeba7
commit
c4d3ec9bff
6 files changed
+366
-14
No files matched your search
+48
-1
@@ -248,6 +248,8 @@
|
||||
<div class="bar"><button id="btnContinueConfirm" class="primary">继续会话</button><span id="continueMsgSpan" class="muted"></span></div>
|
||||
</div>
|
||||
<pre id="detailBody" style="display:none"></pre>
|
||||
<h3>子代理链路</h3>
|
||||
<div id="agentBox" class="muted">点任务行查看详情后,这里显示子代理链路</div>
|
||||
<h3>实时链路</h3>
|
||||
<div id="traceBox" class="muted">点任务行查看详情后,这里显示过程链路</div>
|
||||
<div id="costBox"></div>
|
||||
@@ -269,7 +271,7 @@ var $ = function (s) { return document.querySelector(s); };
|
||||
var KEY_STORE = "codex-console-key";
|
||||
var currentTask = null;
|
||||
var traceAbort = null;
|
||||
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划" };
|
||||
var TRACE_KIND = { exec: "命令", file: "文件", call: "调用", tool: "工具", message: "消息", reasoning: "思考", plan: "计划", "agent-start": "子代理", "agent-done": "子代理" };
|
||||
var TRACE_LIMIT = 1000;
|
||||
var TRACE_TERMINAL = ["completed", "failed", "interrupted", "merged", "merge-failed"];
|
||||
|
||||
@@ -485,6 +487,7 @@ async function loadTasks() {
|
||||
|
||||
function stopTrace() {
|
||||
if (traceAbort) { try { traceAbort.abort(); } catch (e) {} traceAbort = null; }
|
||||
if (agentTimer) { clearTimeout(agentTimer); agentTimer = null; }
|
||||
}
|
||||
function traceLine(e) {
|
||||
var k = TRACE_KIND[e.kind] || e.kind || "?";
|
||||
@@ -496,6 +499,8 @@ function traceLine(e) {
|
||||
else if (e.kind === "tool") body = esc(e.server || "") + "/" + esc(e.tool || "") + " " + esc(e.status || "");
|
||||
else if (e.kind === "call") body = esc(e.name || "");
|
||||
else if (e.kind === "plan") body = esc(e.text || "");
|
||||
else if (e.kind === "agent-start") body = "开始:" + esc(e.name || "") + (e.prompt ? "(" + esc(e.prompt) + ")" : "");
|
||||
else if (e.kind === "agent-done") body = "结束:" + esc(e.name || "") + " " + esc(e.status || "");
|
||||
else body = esc(JSON.stringify(e).slice(0, 300));
|
||||
return "[" + k + "] " + body;
|
||||
}
|
||||
@@ -512,6 +517,44 @@ function renderTrace(entries, note, doneStatus) {
|
||||
if (doneStatus) html += "<br><span class='muted'>链路已结束(" + esc(doneStatus) + ")</span>";
|
||||
box.innerHTML = html;
|
||||
}
|
||||
var AGENT_STATUS = { running: "进行中", completed: "已完成", failed: "失败", interrupted: "已中断", unknown: "未知" };
|
||||
var AGENT_KIND = { main: "主任务", delegate: "委派子任务", model: "模型子代理" };
|
||||
var AGENT_CALL_SHOWN = 50;
|
||||
var agentTimer = null;
|
||||
function renderAgents(data) {
|
||||
var box = $("#agentBox");
|
||||
var html = "";
|
||||
if (!data) { box.innerHTML = "(暂无子代理链路)"; return; }
|
||||
if (data.note) html += "<span class='err'>" + esc(data.note) + "</span><br>";
|
||||
var agents = data.agents || [];
|
||||
if (!agents.length) html += "(暂无子代理)";
|
||||
agents.forEach(function (a) {
|
||||
var st = AGENT_STATUS[a.status] || a.status || "?";
|
||||
html += "<div style='margin:6px 0;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);background:#fff'>";
|
||||
html += "<b>" + esc(AGENT_KIND[a.kind] || a.kind || "") + ":</b>" + esc(a.name || "") +
|
||||
" <span class='st-" + esc(a.status) + "'>" + esc(st) + "</span>" +
|
||||
" | 过程调用 " + (a.calls ? a.calls.length : 0) + " 条" +
|
||||
(a.updated_ts ? " | 最近活动 " + fmt(a.updated_ts / 1000) : "") + "<br>";
|
||||
if (a.prompt) html += "<span class='muted'>任务:" + esc(a.prompt) + "</span><br>";
|
||||
if (a.calls && a.calls.length) {
|
||||
var shown = a.calls.slice(-AGENT_CALL_SHOWN);
|
||||
if (a.calls.length > AGENT_CALL_SHOWN) html += "(仅显示最近 " + AGENT_CALL_SHOWN + " 条,共 " + a.calls.length + " 条)<br>";
|
||||
html += shown.map(traceLine).join("<br>");
|
||||
} else html += "<span class='muted'>(暂无过程调用)</span>";
|
||||
html += "</div>";
|
||||
});
|
||||
box.innerHTML = html;
|
||||
}
|
||||
async function loadAgents(taskId) {
|
||||
try {
|
||||
var d = await api("/tasks/" + encodeURIComponent(taskId) + "/agents", "GET");
|
||||
renderAgents(d);
|
||||
} catch (e) { $("#agentBox").innerHTML = "<span class='err'>子代理链路加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
function scheduleAgents(taskId) {
|
||||
if (agentTimer) clearTimeout(agentTimer);
|
||||
agentTimer = setTimeout(function () { loadAgents(taskId); }, 1000);
|
||||
}
|
||||
async function startTrace(taskId, status) {
|
||||
stopTrace();
|
||||
var box = $("#traceBox");
|
||||
@@ -561,10 +604,12 @@ async function startTrace(taskId, status) {
|
||||
entries.forEach(function (e, i) { index[e.id] = i; });
|
||||
note = m.note || "";
|
||||
renderTrace(entries, note, "");
|
||||
scheduleAgents(taskId);
|
||||
} else if (m.type === "entry") {
|
||||
var e = m.entry;
|
||||
if (e.id in index) entries[index[e.id]] = e; else { index[e.id] = entries.length; entries.push(e); }
|
||||
renderTrace(entries, note, "");
|
||||
scheduleAgents(taskId);
|
||||
} else if (m.type === "done") {
|
||||
done = true; doneStatus = m.status;
|
||||
renderTrace(entries, note, doneStatus);
|
||||
@@ -618,6 +663,8 @@ async function showDetail(taskId) {
|
||||
$("#continueMsgSpan").textContent = "";
|
||||
$("#continueForm").style.display = (TRACE_TERMINAL.indexOf(t.status) >= 0 && t.status !== "merged") ? "block" : "none";
|
||||
startTrace(taskId, t.status);
|
||||
$("#agentBox").innerHTML = "子代理链路加载中…";
|
||||
loadAgents(taskId);
|
||||
loadFollowups(taskId);
|
||||
} catch (e) { $("#detail").innerHTML = "<span class='err'>加载失败:" + esc(e.message) + "</span>"; }
|
||||
}
|
||||
|
||||
+144
@@ -184,6 +184,127 @@ def _read_trace_entries(path):
|
||||
except OSError:
|
||||
return []
|
||||
return entries
|
||||
|
||||
|
||||
AGENT_CALL_LIMIT = 1000
|
||||
|
||||
|
||||
def _read_agent_entries(path):
|
||||
"""读链路文件供子代理聚合:按 (thread, id) 去重,后到的完成态
|
||||
覆盖先到的开始态,保首次顺序;坏行与无 id 行跳过;文件不
|
||||
存在为空。同一 item id 在不同线程出现时不互相覆盖(模型内
|
||||
子代理与主任务各有自己的线程)。"""
|
||||
entries = []
|
||||
index = {}
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(entry, dict) or "id" not in entry:
|
||||
continue
|
||||
key = (entry.get("thread") or "", entry["id"])
|
||||
if key in index:
|
||||
entries[index[key]] = entry
|
||||
else:
|
||||
index[key] = len(entries)
|
||||
entries.append(entry)
|
||||
except OSError:
|
||||
return []
|
||||
return entries
|
||||
|
||||
|
||||
def _latest_ts(values):
|
||||
vals = [v for v in values if isinstance(v, (int, float))]
|
||||
return max(vals) if vals else None
|
||||
|
||||
|
||||
def _agent_status_from_task(status):
|
||||
return {"queued": "running", "running": "running",
|
||||
"completed": "completed", "merged": "completed",
|
||||
"failed": "failed", "merge-failed": "failed",
|
||||
"interrupted": "interrupted"}.get(status, "unknown")
|
||||
|
||||
|
||||
def _build_agent_chain(task, trace_dir):
|
||||
"""任务 → 子代理链:主任务 + 模型内子代理(同文件多线程)+
|
||||
委派子任务(父链路里的 agent-start/done 标记 + 子链路文件)。
|
||||
|
||||
状态诚实口径:委派子任务以 agent-done 标记为准,无标记即
|
||||
进行中——委派进程与父任务相互独立,父任务结束不代表子任务
|
||||
停;模型内子代理无明确结束信号,父任务运行中即进行中,
|
||||
否则未知。无 thread 的旧条目一律归主任务。
|
||||
"""
|
||||
parent_path = os.path.join(
|
||||
trace_dir, _trace_file_name(task["id"]) + ".jsonl")
|
||||
entries = _read_agent_entries(parent_path)
|
||||
calls = [e for e in entries
|
||||
if e.get("kind") not in ("agent-start", "agent-done")]
|
||||
main_thread = None
|
||||
for e in calls:
|
||||
if e.get("thread"):
|
||||
main_thread = e["thread"]
|
||||
break
|
||||
root_calls = [e for e in calls
|
||||
if not e.get("thread") or e.get("thread") == main_thread]
|
||||
agents = [{
|
||||
"id": "main", "name": "主任务", "kind": "main", "parent": None,
|
||||
"status": _agent_status_from_task(task.get("status")),
|
||||
"prompt": None, "trace": None,
|
||||
"calls": root_calls[:AGENT_CALL_LIMIT],
|
||||
"updated_ts": _latest_ts([e.get("ts") for e in root_calls]),
|
||||
}]
|
||||
task_running = task.get("status") in ("queued", "running")
|
||||
threads = {}
|
||||
for e in calls:
|
||||
t = e.get("thread")
|
||||
if t and t != main_thread:
|
||||
threads.setdefault(t, []).append(e)
|
||||
for t, es in threads.items():
|
||||
agents.append({
|
||||
"id": t, "name": "模型子代理 " + t[:12], "kind": "model",
|
||||
"parent": "main",
|
||||
"status": "running" if task_running else "unknown",
|
||||
"prompt": None, "trace": None,
|
||||
"calls": es[:AGENT_CALL_LIMIT],
|
||||
"updated_ts": _latest_ts([e.get("ts") for e in es]),
|
||||
})
|
||||
starts = {}
|
||||
dones = {}
|
||||
for e in entries:
|
||||
if e.get("kind") == "agent-start" and e.get("name"):
|
||||
starts[e["name"]] = e
|
||||
elif e.get("kind") == "agent-done" and e.get("name"):
|
||||
dones[e["name"]] = e
|
||||
for name, s in starts.items():
|
||||
trace_name = s.get("trace") or dones.get(name, {}).get("trace")
|
||||
child_calls = []
|
||||
if trace_name:
|
||||
child_path = os.path.join(
|
||||
trace_dir, os.path.basename(trace_name))
|
||||
child_calls = [e for e in _read_agent_entries(child_path)
|
||||
if e.get("kind") not in
|
||||
("agent-start", "agent-done")]
|
||||
done = dones.get(name)
|
||||
status = "running"
|
||||
if done and done.get("status") in (
|
||||
"completed", "failed", "interrupted"):
|
||||
status = done["status"]
|
||||
agents.append({
|
||||
"id": "delegate:" + name, "name": name, "kind": "delegate",
|
||||
"parent": "main", "status": status,
|
||||
"prompt": s.get("prompt"), "trace": trace_name,
|
||||
"calls": child_calls[:AGENT_CALL_LIMIT],
|
||||
"updated_ts": _latest_ts(
|
||||
[e.get("ts") for e in child_calls] +
|
||||
[s.get("ts"), done.get("ts") if done else None]),
|
||||
})
|
||||
return agents
|
||||
DEFAULT_SWEEP_THRESHOLD_SECONDS = 300
|
||||
# 限流默认值:每 IP / 每 API key 每分钟 120 次;环境变量
|
||||
# CLOUD_RATE_LIMIT_PER_MINUTE / CLOUD_RATE_LIMIT_WINDOW_SECONDS 可调。
|
||||
@@ -1262,6 +1383,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
user_id, rest[: -len("/trace/stream")])
|
||||
elif rest.endswith("/trace") and method == "GET":
|
||||
self._task_trace(user_id, rest[: -len("/trace")])
|
||||
elif rest.endswith("/agents") and method == "GET":
|
||||
self._task_agents(user_id, rest[: -len("/agents")])
|
||||
elif rest.endswith("/cancel") and method == "POST":
|
||||
self._cancel_task(user_id, rest[: -len("/cancel")])
|
||||
elif rest.endswith("/merge") and method == "POST":
|
||||
@@ -2471,6 +2594,27 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||||
"entries": entries, "note": note})
|
||||
|
||||
def _task_agents(self, user_id, task_id):
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
self._send_json(404, {"error": "task not found"})
|
||||
return
|
||||
task = _task_to_dict(row)
|
||||
with _auth_store(self.server.db_path) as store:
|
||||
if not store.check_permission(user_id, task["project_id"], "read"):
|
||||
self._send_json(403, {"error": "forbidden"})
|
||||
return
|
||||
worktree_host = task.get("worktree_host")
|
||||
if worktree_host and worktree_host != socket.gethostname():
|
||||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||||
"agents": [],
|
||||
"note": ("链路文件在 worktree 主机 %s 上,本机读不到"
|
||||
% worktree_host)})
|
||||
return
|
||||
agents = _build_agent_chain(task, _trace_dir_for(self.server))
|
||||
self._send_json(200, {"task_id": task_id, "status": task["status"],
|
||||
"agents": agents, "note": ""})
|
||||
|
||||
def _task_trace_stream(self, user_id, task_id):
|
||||
row = self._fetch_task(task_id)
|
||||
if row is None:
|
||||
|
||||
@@ -64,7 +64,7 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
self.assertIn("Codex 命令台", text)
|
||||
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
||||
for marker in ("/health", "/projects", "/tasks", "/workers",
|
||||
"X-API-Key"):
|
||||
"X-API-Key", "agentBox", "/agents"):
|
||||
self.assertIn(marker, text)
|
||||
|
||||
def test_console_trailing_slash_same_page(self):
|
||||
|
||||
@@ -173,6 +173,79 @@ class TaskTraceTestCase(unittest.TestCase):
|
||||
self.assertEqual(status, 401)
|
||||
self.assertEqual(events, [])
|
||||
|
||||
def test_agents_auth_and_not_found(self):
|
||||
status, _ = self._req(
|
||||
"GET", "/tasks/%s/agents" % self.task_id)
|
||||
self.assertEqual(status, 401)
|
||||
status, _ = self._req(
|
||||
"GET", "/tasks/%s/agents" % self.task_id, key=self.other_key)
|
||||
self.assertEqual(status, 403)
|
||||
status, _ = self._req(
|
||||
"GET", "/tasks/nope/agents", key=self.owner_key)
|
||||
self.assertEqual(status, 404)
|
||||
|
||||
def test_agents_chain_groups_calls_by_agent(self):
|
||||
self._set_task(status="running")
|
||||
self._write_trace([
|
||||
json.dumps({"kind": "exec", "id": "c1", "thread": "th-main",
|
||||
"command": "make", "ts": 1000}),
|
||||
json.dumps({"kind": "tool", "id": "t1", "thread": "th-sub",
|
||||
"server": "s", "tool": "read", "ts": 1001}),
|
||||
json.dumps({"kind": "agent-start", "id": "agent:rev",
|
||||
"name": "rev", "prompt": "review code",
|
||||
"trace": "rev.jsonl", "ts": 1002}),
|
||||
json.dumps({"kind": "agent-done", "id": "agent:rev:done",
|
||||
"name": "rev", "status": "completed",
|
||||
"trace": "rev.jsonl", "ts": 1004}),
|
||||
])
|
||||
with open(os.path.join(self.trace_dir, "rev.jsonl"),
|
||||
"w", encoding="utf-8") as f:
|
||||
f.write(json.dumps({"kind": "exec", "id": "c9",
|
||||
"command": "grep", "ts": 1003}) + "\n")
|
||||
status, data = self._req(
|
||||
"GET", "/tasks/%s/agents" % self.task_id, key=self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data["status"], "running")
|
||||
by_kind = {a["kind"]: a for a in data["agents"]}
|
||||
main = by_kind["main"]
|
||||
self.assertEqual(main["status"], "running")
|
||||
self.assertEqual([e["id"] for e in main["calls"]], ["c1"])
|
||||
model = by_kind["model"]
|
||||
self.assertEqual(model["status"], "running")
|
||||
self.assertEqual([e["id"] for e in model["calls"]], ["t1"])
|
||||
delegate = [a for a in data["agents"]
|
||||
if a["kind"] == "delegate"][0]
|
||||
self.assertEqual(delegate["name"], "rev")
|
||||
self.assertEqual(delegate["status"], "completed")
|
||||
self.assertEqual(delegate["prompt"], "review code")
|
||||
self.assertEqual([e["id"] for e in delegate["calls"]], ["c9"])
|
||||
self.assertEqual(delegate["updated_ts"], 1004)
|
||||
|
||||
def test_agents_delegate_without_done_stays_running(self):
|
||||
# 委派进程与父任务相互独立:父任务已结束、无结束标记时,
|
||||
# 子任务仍算进行中(不能谎报它已中断)。
|
||||
self._set_task(status="completed")
|
||||
self._write_trace([
|
||||
json.dumps({"kind": "agent-start", "id": "agent:rev",
|
||||
"name": "rev", "prompt": "review",
|
||||
"trace": "rev.jsonl", "ts": 1002}),
|
||||
])
|
||||
status, data = self._req(
|
||||
"GET", "/tasks/%s/agents" % self.task_id, key=self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
delegate = [a for a in data["agents"]
|
||||
if a["kind"] == "delegate"][0]
|
||||
self.assertEqual(delegate["status"], "running")
|
||||
self.assertEqual(data["agents"][0]["status"], "completed")
|
||||
|
||||
def test_agents_missing_file_is_main_only(self):
|
||||
status, data = self._req(
|
||||
"GET", "/tasks/%s/agents" % self.task_id, key=self.owner_key)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data["agents"]), 1)
|
||||
self.assertEqual(data["agents"][0]["kind"], "main")
|
||||
self.assertEqual(data["agents"][0]["calls"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user