Files
GenericAgent/frontends/conductor.py
T
yiqi-017 90a37236aa feat(desktop): conductor token stats summary row in token page
- conductor.py: install cost_tracker + expose /token-stats endpoint (5 lines)
- app.js: fetch conductor token totals and display as a single summary row at top of token table (not expandable, with tooltip noting it's excluded from cumulative totals)
- styles.css: conductor row styling
2026-05-29 23:03:53 +08:00

437 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os, sys, re, time, json, uuid, queue, asyncio, threading, builtins
from dataclasses import dataclass, field
from typing import Dict, Any, Optional, List
# Silence print() from subagent threads (they share stdout with conductor)
_original_print = builtins.print
def _filtered_print(*args, **kwargs):
t = threading.current_thread()
if t.name.startswith('subagent-'):
return
return _original_print(*args, **kwargs)
builtins.print = _filtered_print
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, PlainTextResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# allow: python frontends/conductor.py
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from agentmain import GenericAgent
HOST = "127.0.0.1"
PORT = 8900
HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "conductor.html")
app = FastAPI(title="Conductor")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class ChatIn(BaseModel):
msg: str
role: str = "conductor" # conductor | system | user
class StartSubagentIn(BaseModel):
prompt: str
class SubagentActionIn(BaseModel):
action: str = "intervene" # intervene | abort | kill
msg: str = ""
@dataclass
class SubAgentState:
id: str
agent: GenericAgent
prompt: str
reply: str = ""
status: str = "running" # running | stopped | failed | aborted
created_at: float = field(default_factory=time.time)
updated_at: float = field(default_factory=time.time)
last_done: str = ""
monitor_threads: List[threading.Thread] = field(default_factory=list)
subagents: Dict[str, SubAgentState] = {}
sub_lock = threading.RLock()
ws_clients: set[WebSocket] = set()
main_loop: Optional[asyncio.AbstractEventLoop] = None
# conductor event queue: only user messages and subagent-done events enter here.
conductor_events: "queue.Queue[dict]" = queue.Queue()
conductor_agent: Optional[GenericAgent] = None
conductor_started = False
chat_messages: List[dict] = []
def now_ms() -> int:
return int(time.time() * 1000)
def short_id() -> str:
return uuid.uuid4().hex[:8]
_TURN_SPLIT_RE = re.compile(r'\**LLM Running \(Turn \d+\) \.\.\.\**')
_SUMMARY_RE = re.compile(r'<summary>(.*?)</summary>\s*', re.DOTALL)
def extract_last_summary(full: str) -> str:
"""Extract the latest <summary> content for in-progress display."""
matches = _SUMMARY_RE.findall(full or "")
if not matches:
return ""
s = matches[-1].strip()
return s[-1000:] if len(s) > 1000 else s
def extract_last_text_reply(full: str) -> str:
"""Extract only the last turn's text reply (like stapp.py fold_turns logic)."""
# Split by turn markers, take last segment
parts = _TURN_SPLIT_RE.split(full)
last = parts[-1] if parts else full
# Strip <summary> tags
last = _SUMMARY_RE.sub('', last)
# Strip [Status] and [Info] lines
last = re.sub(r'\[(Status|Info)\][^\n]*\n?', '', last)
# Strip trailing whitespace
last = last.strip()
# Cap length
return last[-3000:] if len(last) > 3000 else last
def subagent_snapshot() -> list[dict]:
with sub_lock:
return [
{
"id": s.id,
"prompt": s.prompt,
"reply": (extract_last_summary(s.reply) if s.status == "running" else extract_last_text_reply(s.reply)) if s.reply else "",
"status": s.status,
"created_at": s.created_at,
"updated_at": s.updated_at,
}
for s in subagents.values()
if s.status != "aborted"
]
def schedule_broadcast(payload: dict):
if main_loop and main_loop.is_running():
asyncio.run_coroutine_threadsafe(broadcast(payload), main_loop)
async def broadcast(payload: dict):
dead = []
for ws in list(ws_clients):
try:
await ws.send_json(payload)
except Exception:
dead.append(ws)
for ws in dead:
ws_clients.discard(ws)
def push_cards():
schedule_broadcast({"type": "subagents", "items": subagent_snapshot()})
def add_chat(msg: str, role: str = "conductor", files: list = None, images: list = None):
item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user", "files": files or [], "images": images or []}
chat_messages.append(item)
if len(chat_messages) > 200:
del chat_messages[:-200]
schedule_broadcast({"type": "chat", "item": item})
return item
def start_agent_runner(agent: GenericAgent, name: str):
t = threading.Thread(target=agent.run, name=name, daemon=True)
t.start()
return t
def monitor_display_queue(agent_id: str, dq: "queue.Queue", trigger_when_done: bool):
"""Consume one GenericAgent display_queue.
next: update card only, never wake conductor.
done: update card/chat, then wake conductor if this is subagent queue.
"""
acc = ""
while True:
item = dq.get()
if "next" in item:
chunk = item.get("next") or ""
# agent.inc_out=True means next is delta.
acc += chunk
with sub_lock:
s = subagents.get(agent_id)
if s:
s.reply = acc
s.status = "running"
s.updated_at = time.time()
push_cards()
if "done" in item:
done = item.get("done") or acc
with sub_lock:
s = subagents.get(agent_id)
if s:
s.reply = done
s.last_done = done
if s.status != "aborted":
s.status = "stopped"
s.updated_at = time.time()
push_cards()
if trigger_when_done:
conductor_events.put({"type": "subagent_done", "id": agent_id, "reply": done})
break
def start_subagent(prompt: str) -> dict:
sid = short_id()
agent = GenericAgent()
agent.inc_out = True
agent.verbose = False
start_agent_runner(agent, f"subagent-{sid}")
state = SubAgentState(id=sid, agent=agent, prompt=prompt, status="running")
with sub_lock:
subagents[sid] = state
dq = agent.put_task(prompt, source=f"subagent:{sid}")
mt = threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True)
mt.start()
state.monitor_threads.append(mt)
push_cards()
return {"id": sid, "status": "running"}
def keyinfo_subagent(sid: str, msg: str) -> dict:
"""Inject into agent's working key_info; visible from next turn onward."""
with sub_lock:
s = subagents.get(sid)
if not s:
return {"error": "subagent not found", "id": sid}
h = s.agent.handler
h.working['key_info'] = h.working.get('key_info', '') + f"\n[MASTER] {msg}"
s.updated_at = time.time()
return {"id": sid, "status": "keyinfo_injected"}
def input_subagent(sid: str, msg: str) -> dict:
"""Start a new task round (used for input/reply when agent is stopped)."""
with sub_lock:
s = subagents.get(sid)
if not s:
return {"error": "subagent not found", "id": sid}
if s.status == "running":
return {"error": "subagent is still running, cannot input/reply. Start a new subagent instead.", "id": sid}
s.prompt = msg
s.reply = ""
s.status = "running"
s.updated_at = time.time()
dq = s.agent.put_task(msg, source=f"subagent:{sid}")
mt = threading.Thread(target=monitor_display_queue, args=(sid, dq, True), name=f"monitor-{sid}", daemon=True)
mt.start()
s.monitor_threads.append(mt)
push_cards()
return {"id": sid, "status": "running"}
def conductor_readme() -> str:
base = f"http://{HOST}:{PORT}"
return "\n".join([
f"Conductor API\tBase: {base}",
"",
"POST /chat\tbody: {\"msg\": \"...\"}\t给用户发消息",
"POST /subagent\tbody: {\"prompt\": \"...\"}\t启动新subagent,返回 {\"id\": \"xxx\"}",
'POST /subagent/{id}\tbody: {\"action\": \"keyinfo\", \"msg\": \"...\"}\t注入key_info(agent下轮可见)',
'POST /subagent/{id}\tbody: {\"action\": \"input\", \"msg\": \"...\"}\t开新一轮任务(agent停下后追加)',
'POST /subagent/{id}\tbody: {\"action\": \"stop\"}\t中断执行但保留(可继续input/reply)',
'POST /subagent/{id}\tbody: {\"action\": \"kill\"}\t彻底杀死(从卡片消失,不可复用)',
"GET /chat?last=N\t返回最近N条对话(默认20)",
"GET /subagent\t返回 {\"items\": [...]}\t查看所有subagent状态",
"GET /readme\t本文档",
"",
"触发时机: 用户新消息 | subagent done",
])
def conductor_prompt_from_events(events: list) -> str:
# 极简摘要:subagent数量和状态
with sub_lock:
running = sum(1 for s in subagents.values() if s.status == "running")
stopped = sum(1 for s in subagents.values() if s.status != "running")
unread = sum(1 for m in chat_messages if m.get("role") == "user" and not m.get("read"))
done_count = sum(1 for e in events if e.get("type") == "subagent_done")
summary = f"subagents: {running} running, {stopped} stopped | {unread}条用户未读消息, {done_count}个subagent完成报告"
base = f"http://{HOST}:{PORT}"
return f"""你是agent总管。用户只和你对话,你负责调度、验收、交付,目标是降低用户管理多个agent的负担。
API: {base};先requests,GET /readme查用法,GET /chat读未读对话,GET /subagent看状态;POST /chat是唯一对用户说话方式。
铁律:
- 绝不亲自执行任务/探测环境;一切执行交给subagent。你只分析、派遣、审查、沟通。
- 每次唤醒只做最小必要动作(发消息/开subagent/reply/keyinfo/abort),做完立刻停,等待下次事件唤醒。
- 改写prompt时严禁添加用户未提及的假设、工具、前提条件。只能精炼/结构化用户原意,不能脑补,只能做很小的改写
用户消息流程:
1. 结合记忆、上下文和用户偏好判断真实需求;不清楚/不能代劳时,用精简checklist一次性问用户。
2. 判断是新任务还是延续现有任务;优先复用已有stopped subagent(用input追加),只有确实无关的新任务才新建。
3. 分派前必须POST /chat告知用户:改写后的prompt + 分派方案(新建/复用哪个subagent)。
4. 执行分派,完成即停。危险操作(改源码/删数据/安全敏感)必须改成先让subagent出方案;你验收后POST /chat请用户确认,确认后才继续执行。
subagent完成流程:
1. 读subagent输出;若最后一条不足以判断,GET /subagent或日志补足信息。
2. 预测用户是否满意;不满意就reply/keyinfo要求返工、修改、优化,继续监督,不急着报告。
3. 预计用户满意后,POST /chat给简洁交付报告。
原则:
- 信任subagent足够聪明,不要写具体步骤和容易探测的信息;能自己判断的自己判断,只在真正需要用户决策时打扰。
{summary}"""
def _auto_cleanup_loop():
"""Background: auto-abort stopped subagents idle for >1 hour."""
IDLE_TIMEOUT = 3600 # 1 hour
while True:
time.sleep(300) # check every 5 minutes
now = time.time()
to_abort = []
with sub_lock:
for sid, s in subagents.items():
if s.status == "stopped" and (now - s.updated_at) > IDLE_TIMEOUT:
to_abort.append((sid, s))
for sid, s in to_abort:
s.agent.abort()
with sub_lock:
s.status = "aborted"
s.updated_at = now
if to_abort:
push_cards()
def monitor_conductor_queue(dq: "queue.Queue") -> str:
"""Block until done. Conductor output is not surfaced to users; the returned
text is for caller-side error detection only (#342)."""
while True:
item = dq.get()
if "done" in item:
print(f"Conductor task done")
return item.get("done", "") or ""
def conductor_loop():
global conductor_agent, conductor_started
conductor_agent = GenericAgent()
conductor_agent.inc_out = True
start_agent_runner(conductor_agent, "conductor-agent")
conductor_started = True
# Start background cleanup thread
threading.Thread(target=_auto_cleanup_loop, name="subagent-cleanup", daemon=True).start()
while True:
# Block until first event arrives
first = conductor_events.get()
conductor_events.task_done()
# Short debounce: collect any additional events that arrived meanwhile
time.sleep(0.3)
events = [first]
while not conductor_events.empty():
try:
events.append(conductor_events.get_nowait())
conductor_events.task_done()
except Exception:
break
try:
prompt = conductor_prompt_from_events(events)
dq = conductor_agent.put_task(prompt, source="conductor")
# Block here until conductor finishes — serializes execution
done_text = monitor_conductor_queue(dq)
# Fallback: if conductor's last turn ended with an LLM error (yielded as
# text by MixinSession instead of raised), surface it directly (#342).
# Use tail-window substring check (same style as ga.do_no_tool's
# content[-100:]); window covers the error line plus any decoration
# (fence + "[Info] Final response to user.") appended by do_no_tool.
tail = (done_text or '')[-1000:]
if '!!!Error:' in tail:
last = chat_messages[-1] if chat_messages else None
if not (last and last.get('role') == 'system' and last.get('msg', '').startswith('⚠ LLM')):
err = next((l for l in reversed(tail.splitlines()) if l.startswith('!!!Error:')), '')
add_chat(f"⚠ LLM 暂不可用:{err[:200]}", role="system")
except Exception as e:
add_chat(f"Conductor error: {e}", role="system")
@app.on_event("startup")
async def on_startup():
global main_loop
main_loop = asyncio.get_running_loop()
import cost_tracker; cost_tracker.install()
threading.Thread(target=conductor_loop, name="conductor-loop", daemon=True).start()
@app.get("/token-stats")
def conductor_token_stats():
import cost_tracker
return {"records": [{"thread": k, "input": v.input, "output": v.output, "cacheCreate": v.cache_create, "cacheRead": v.cache_read} for k, v in cost_tracker.all_trackers().items()]}
@app.get("/")
def index():
return FileResponse(HTML_PATH)
@app.get("/readme")
def readme():
return PlainTextResponse(conductor_readme())
@app.get("/subagent")
def list_subagents():
return {"items": subagent_snapshot()}
@app.post("/subagent")
def api_start_subagent(body: StartSubagentIn):
result = start_subagent(body.prompt)
result["instruction"] = "Task received. I'll handle it from here. You MUST stop now and end your reply. Wait for next event."
return result
@app.post("/subagent/{sid}")
def api_subagent_action(sid: str, body: SubagentActionIn):
with sub_lock:
s = subagents.get(sid)
if not s:
return JSONResponse({"error": "subagent not found", "id": sid}, status_code=404)
action = body.action.lower().strip()
if action == "keyinfo":
result = keyinfo_subagent(sid, body.msg)
result["instruction"] = "Received. I'll incorporate this. You MUST stop now and end your reply."
return result
if action in ("input", "reply", "append", "message", "msg"):
result = input_subagent(sid, body.msg)
result["instruction"] = "Task received. I'll handle it from here. You MUST stop now and end your reply."
return result
if action in ("abort", "stop"):
s.agent.abort()
s.status = "stopped"
s.updated_at = time.time()
push_cards()
return {"id": sid, "status": "stopped"}
if action == "kill":
s.agent.abort()
s.status = "aborted"
s.updated_at = time.time()
push_cards()
return {"id": sid, "status": "aborted"}
return JSONResponse({"error": f"unknown action: {body.action}"}, status_code=400)
@app.get("/chat")
def api_get_chat(last: int = 20):
"""按需拉取最近N条对话,同时标记所有用户消息为已读"""
for m in chat_messages:
if m.get("role") == "user" and not m.get("read"):
m["read"] = True
schedule_broadcast({"type": "chat_read"})
return {"items": chat_messages[-last:]}
@app.post("/chat")
def api_chat(body: ChatIn):
return add_chat(body.msg, role=body.role)
@app.websocket("/ws")
async def websocket(ws: WebSocket):
await ws.accept()
ws_clients.add(ws)
try:
running = any(s.status == "running" for s in subagents.values())
await ws.send_json({"type": "hello", "subagents": subagent_snapshot(), "chat": chat_messages, "running": running})
while True:
data = await ws.receive_json()
msg = (data.get("msg") or "").strip()
if not msg:
continue
add_chat(msg, role="user", files=data.get("files") or [], images=data.get("images") or [])
conductor_events.put({"type": "user_message", "msg": msg})
except WebSocketDisconnect:
pass
finally:
ws_clients.discard(ws)
if __name__ == "__main__":
import uvicorn, webbrowser, threading
threading.Timer(1.0, lambda: webbrowser.open(f"http://{HOST}:{PORT}")).start()
uvicorn.run("conductor:app", host=HOST, port=PORT, reload=False)