feat: add conductor system, code_review_principles; update llmcore, supervisor_sop, goal_mode constraints, gitignore whitelist
This commit is contained in:
1 parent
e4c39b16d3
commit
a9c6ab42c7
7 files changed
+905
-2
No files matched your search
@@ -94,6 +94,9 @@ memory/L4_raw_sessions/*
|
||||
# Memory management
|
||||
!memory/memory_cleanup_sop.md
|
||||
|
||||
# Code Review Principles
|
||||
!memory/code_review_principles.md
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
restore_commit.txt
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Conductor Streamlit</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f7fb;
|
||||
--panel: rgba(255,255,255,0.78);
|
||||
--panel-2: rgba(255,255,255,0.92);
|
||||
--border: rgba(31,42,68,0.10);
|
||||
--text: #172033;
|
||||
--muted: #7a8497;
|
||||
--soft: #43506a;
|
||||
--accent: #3b82f6;
|
||||
--accent-2: #8b5cf6;
|
||||
--green: #10b981;
|
||||
--amber: #f59e0b;
|
||||
--red: #ef4444;
|
||||
--gray: #9ca3af;
|
||||
--shadow: 0 18px 55px rgba(30, 45, 80, .11);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
font-family: Inter, "SF Pro Display", "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(900px 600px at 12% 8%, rgba(59, 130, 246, .14), transparent 54%),
|
||||
radial-gradient(800px 650px at 86% 18%, rgba(139, 92, 246, .12), transparent 58%),
|
||||
radial-gradient(650px 520px at 61% 94%, rgba(16, 185, 129, .10), transparent 60%),
|
||||
linear-gradient(180deg, #fbfcff, var(--bg));
|
||||
}
|
||||
|
||||
.app {
|
||||
height: 100vh;
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 45%) 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.pane {
|
||||
min-height: 0;
|
||||
border: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.86), rgba(255,255,255,.66));
|
||||
backdrop-filter: blur(18px);
|
||||
border-radius: 26px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.left, .chat { display: flex; flex-direction: column; }
|
||||
|
||||
.head {
|
||||
padding: 20px 20px 14px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgba(31,42,68,.075);
|
||||
background: rgba(255,255,255,.34);
|
||||
}
|
||||
.title {
|
||||
font-size: 18px;
|
||||
letter-spacing: .2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.cards::-webkit-scrollbar, .messages::-webkit-scrollbar { width: 8px; }
|
||||
.cards::-webkit-scrollbar-thumb, .messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(31,42,68,.16);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
padding: 15px 15px 14px 17px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255,255,255,.76);
|
||||
border: 1px solid rgba(31,42,68,.10);
|
||||
box-shadow: 0 8px 24px rgba(30,45,80,.055);
|
||||
transition: transform .18s ease, background .18s ease, border-color .18s ease, box-shadow .18s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-1px);
|
||||
background: rgba(255,255,255,.95);
|
||||
border-color: rgba(59,130,246,.24);
|
||||
box-shadow: 0 12px 30px rgba(30,45,80,.08);
|
||||
}
|
||||
.card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0; top: 16px; bottom: 16px;
|
||||
width: 4px;
|
||||
border-radius: 99px;
|
||||
background: var(--state);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 84%);
|
||||
}
|
||||
.card.running { --state: var(--green); }
|
||||
.card.waiting { --state: var(--amber); }
|
||||
.card.stopped { --state: var(--gray); }
|
||||
.card.failed { --state: var(--red); }
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.dot-text {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--state);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--state), transparent 86%);
|
||||
}
|
||||
.label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
letter-spacing: .10em;
|
||||
}
|
||||
.prompt {
|
||||
color: #24314d;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reply {
|
||||
color: var(--soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
background: rgba(245,247,251,.78);
|
||||
border: 1px solid rgba(31,42,68,.07);
|
||||
border-radius: 14px;
|
||||
padding: 11px 12px;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.reply p, .reply ul, .reply ol, .reply pre, .reply blockquote {
|
||||
margin: 0 0 .25em 0;
|
||||
}
|
||||
.reply p:last-child, .reply ul:last-child, .reply ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.reply li { margin: 0; line-height: 1.35; }
|
||||
.reply code {
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,.05);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.reply pre { white-space: pre-wrap; margin: .2em 0; }
|
||||
.reply pre code { background: rgba(0,0,0,.04); display: block; padding: 6px 8px; border-radius: 5px; }
|
||||
.reply::-webkit-scrollbar { width: 5px; }
|
||||
.reply::-webkit-scrollbar-thumb {
|
||||
background: rgba(31,42,68,.14);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.msg {
|
||||
max-width: 78%;
|
||||
padding: 13px 15px;
|
||||
border-radius: 18px;
|
||||
line-height: 1.45;
|
||||
font-size: 14px;
|
||||
word-wrap: break-word;
|
||||
box-shadow: 0 8px 22px rgba(30,45,80,.055);
|
||||
}
|
||||
.msg p, .msg ul, .msg ol, .msg pre, .msg blockquote {
|
||||
margin: 0 0 .3em 0;
|
||||
}
|
||||
.msg p:last-child, .msg ul:last-child, .msg ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.msg li { margin: 0; line-height: 1.4; }
|
||||
.msg code {
|
||||
font-size: 12px;
|
||||
background: rgba(0,0,0,.05);
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.msg pre { white-space: pre-wrap; margin: .3em 0; }
|
||||
.msg pre code { background: rgba(0,0,0,.04); display: block; padding: 6px 8px; border-radius: 6px; }
|
||||
.msg.me {
|
||||
align-self: flex-end;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #3b82f6, #7c3aed);
|
||||
border: 1px solid rgba(59,130,246,.20);
|
||||
border-bottom-right-radius: 7px;
|
||||
}
|
||||
.msg.me.unread {
|
||||
box-shadow: 0 0 0 2px #f59e0b, 0 8px 22px rgba(30,45,80,.055);
|
||||
}
|
||||
.msg.me.unread::after {
|
||||
content: '未读';
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
opacity: .85;
|
||||
color: #fbbf24;
|
||||
}
|
||||
.msg.ai {
|
||||
align-self: flex-start;
|
||||
background: rgba(255,255,255,.88);
|
||||
border: 1px solid rgba(31,42,68,.10);
|
||||
border-bottom-left-radius: 7px;
|
||||
}
|
||||
.msg.system {
|
||||
align-self: center;
|
||||
max-width: 88%;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
padding: 7px 11px;
|
||||
border-radius: 99px;
|
||||
background: rgba(255,255,255,.70);
|
||||
border: 1px solid rgba(31,42,68,.08);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid rgba(31,42,68,.075);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
background: rgba(255,255,255,.40);
|
||||
}
|
||||
textarea {
|
||||
flex: 1;
|
||||
height: 52px;
|
||||
max-height: 140px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: rgba(255,255,255,.92);
|
||||
border: 1px solid rgba(31,42,68,.12);
|
||||
border-radius: 18px;
|
||||
padding: 14px 15px;
|
||||
font: inherit;
|
||||
line-height: 1.35;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.8);
|
||||
}
|
||||
textarea:focus {
|
||||
border-color: rgba(59,130,246,.44);
|
||||
box-shadow: 0 0 0 4px rgba(59,130,246,.10);
|
||||
}
|
||||
textarea::placeholder { color: #98a1b2; }
|
||||
button {
|
||||
height: 52px;
|
||||
min-width: 92px;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
color: white;
|
||||
font-weight: 750;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, #3b82f6, #10b981);
|
||||
box-shadow: 0 10px 26px rgba(59,130,246,.22);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app { grid-template-columns: 1fr; grid-template-rows: 42% 1fr; padding: 12px; }
|
||||
.msg { max-width: 92%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="app">
|
||||
<section class="pane left">
|
||||
<div class="head">
|
||||
<div class="title">Conductor</div>
|
||||
<div class="hint">subagents · live</div>
|
||||
</div>
|
||||
<div class="cards" id="cards">
|
||||
<div class="empty">暂无 subagent。你可以在右侧告诉 Conductor 要做什么。</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pane chat">
|
||||
<div class="head">
|
||||
<div class="title">Conversation</div>
|
||||
<div class="hint" id="wsStatus">connecting...</div>
|
||||
</div>
|
||||
<div class="messages" id="messages">
|
||||
<div class="msg system">Conductor 正在启动,等待 WebSocket 连接。</div>
|
||||
</div>
|
||||
<div class="composer">
|
||||
<textarea id="input" placeholder="告诉 Conductor 你想做什么……"></textarea>
|
||||
<button id="send">发送</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
const cardsEl = document.getElementById('cards');
|
||||
const messagesEl = document.getElementById('messages');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send');
|
||||
const wsStatus = document.getElementById('wsStatus');
|
||||
let ws;
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]));
|
||||
}
|
||||
|
||||
function renderMd(s) {
|
||||
try { return marked.parse(s || ''); } catch(e) { return esc(s); }
|
||||
}
|
||||
|
||||
function renderCards(items) {
|
||||
if (!items || !items.length) {
|
||||
cardsEl.innerHTML = '<div class="empty">暂无 subagent。你可以在右侧告诉 Conductor 要做什么。</div>';
|
||||
return;
|
||||
}
|
||||
cardsEl.innerHTML = items.map(it => {
|
||||
const status = it.status || 'stopped';
|
||||
return `
|
||||
<article class="card ${esc(status)}">
|
||||
<div class="status-row"><span class="dot-text"><span class="dot"></span>${esc(status)}</span></div>
|
||||
<div class="prompt">${esc(it.prompt)}</div>
|
||||
<div class="reply">${renderMd(it.reply || '等待输出……')}</div>
|
||||
</article>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function addMsg(item) {
|
||||
const role = item.role || 'system';
|
||||
const cls = role === 'user' ? 'me' : (role === 'conductor' ? 'ai' : 'system');
|
||||
const div = document.createElement('div');
|
||||
div.className = `msg ${cls}` + (role === 'user' && !item.read ? ' unread' : '');
|
||||
if (item.id) div.dataset.id = item.id;
|
||||
div.innerHTML = renderMd(item.msg || '');
|
||||
messagesEl.appendChild(div);
|
||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
messagesEl.querySelectorAll('.msg.me.unread').forEach(el => el.classList.remove('unread'));
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
ws.onopen = () => wsStatus.textContent = 'connected';
|
||||
ws.onclose = () => {
|
||||
wsStatus.textContent = 'disconnected · retrying';
|
||||
setTimeout(connect, 1200);
|
||||
};
|
||||
ws.onerror = () => wsStatus.textContent = 'error';
|
||||
ws.onmessage = ev => {
|
||||
const data = JSON.parse(ev.data);
|
||||
if (data.type === 'hello') {
|
||||
renderCards(data.subagents || []);
|
||||
messagesEl.innerHTML = '';
|
||||
(data.chat || []).forEach(addMsg);
|
||||
if (!(data.chat || []).length) addMsg({role:'system', msg:'Conductor 已启动,正在监听用户消息和 subagent 输出。'});
|
||||
} else if (data.type === 'subagents') {
|
||||
renderCards(data.items || []);
|
||||
} else if (data.type === 'chat') {
|
||||
addMsg(data.item);
|
||||
} else if (data.type === 'chat_read') {
|
||||
markAllRead();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function send() {
|
||||
const msg = inputEl.value.trim();
|
||||
if (!msg || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
ws.send(JSON.stringify({msg}));
|
||||
inputEl.value = '';
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', send);
|
||||
inputEl.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,434 @@
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
import queue
|
||||
import asyncio
|
||||
import threading
|
||||
import 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 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")
|
||||
|
||||
|
||||
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()
|
||||
]
|
||||
|
||||
|
||||
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"):
|
||||
item = {"id": short_id(), "role": role, "msg": msg, "ts": now_ms(), "read": role != "user"}
|
||||
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\": \"abort\"}\t停止该subagent',
|
||||
"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总管。用户只和你对话,你负责一切执行细节。
|
||||
API: {base}
|
||||
|
||||
铁律:
|
||||
- 你绝不亲自执行任何任务,一切工作必须通过POST /subagent分派。你只做调度、审查、沟通。
|
||||
- 分派完毕后立刻结束本轮回复(停下来),等待下次轮询唤醒你。绝不阻塞等待subagent结果。
|
||||
- 每次唤醒只做最小必要动作(发消息/开subagent/reply subagent),然后立刻停。
|
||||
|
||||
职责:
|
||||
- 理解用户意图,从记忆和上下文揣摩真实需要,不反复确认显而易见的事
|
||||
- 涉及改源码、删数据、安全敏感操作,需要改写prompt让subagent先产生方案,然后二次确认执行
|
||||
- 不准自己探测环境!!!快速转派给subagent以处理用户下一个指令
|
||||
|
||||
- 判断用户意图是新任务还是对现有任务的延续,小心分派subagent
|
||||
- 必要时对subagent追加指令或keyinfo引导
|
||||
- 有无法确定/无法代劳的事项,列精简checklist一次性问用户(不要一个个问)
|
||||
- 任务完成后给用户简洁报告
|
||||
|
||||
原则:
|
||||
- 能自己决定的自己决定,只在真正需要用户判断时才打扰
|
||||
- 验收优先:subagent完成后,先自己审查结果,有问题直接reply让它继续修,反复直到质量过关再报告用户
|
||||
- POST /chat 是你和用户说话的唯一方式(必须调接口,不要只在回复里写)
|
||||
- GET /readme 查API用法,GET /chat 看对话历史,GET /subagent 看状态
|
||||
{summary}"""
|
||||
|
||||
|
||||
def monitor_conductor_queue(dq: "queue.Queue"):
|
||||
"""Conductor output is discarded. Visible reply must go through POST /chat only."""
|
||||
while True:
|
||||
item = dq.get()
|
||||
if "done" in item:
|
||||
break
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
monitor_conductor_queue(dq)
|
||||
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()
|
||||
threading.Thread(target=conductor_loop, name="conductor-loop", daemon=True).start()
|
||||
|
||||
|
||||
@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):
|
||||
return start_subagent(body.prompt)
|
||||
|
||||
|
||||
@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":
|
||||
return keyinfo_subagent(sid, body.msg)
|
||||
if action in ("input", "reply", "append", "message", "msg"):
|
||||
return input_subagent(sid, body.msg)
|
||||
if action == "abort":
|
||||
s.agent.abort()
|
||||
s.status = "aborted"
|
||||
s.updated_at = time.time()
|
||||
push_cards()
|
||||
conductor_events.put({"type": "subagent_aborted", "id": sid})
|
||||
return {"id": sid, "status": "aborted"}
|
||||
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:
|
||||
await ws.send_json({"type": "hello", "subagents": subagent_snapshot(), "chat": chat_messages})
|
||||
while True:
|
||||
data = await ws.receive_json()
|
||||
msg = (data.get("msg") or "").strip()
|
||||
if not msg:
|
||||
continue
|
||||
add_chat(msg, role="user")
|
||||
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)
|
||||
+3
-1
@@ -373,7 +373,9 @@ def _stream_with_retry(sess, url, headers, payload, parse_fn):
|
||||
gen = parse_fn(r)
|
||||
try:
|
||||
while True: streamed = True; yield next(gen)
|
||||
except StopIteration as e: return e.value or []
|
||||
except StopIteration as e:
|
||||
if not e.value and not streamed: raise requests.ConnectionError("empty response")
|
||||
return e.value or []
|
||||
except (requests.Timeout, requests.ConnectionError) as e:
|
||||
err = f"!!!Error: {type(e).__name__}"
|
||||
if attempt < sess.max_retries:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 什么是好的代码
|
||||
好的代码不是"能跑就行",而是在长期演化中保持**压缩性、局部性、可组合性与可证伪性**。
|
||||
一句话判断:同样的功能,用最小必要的结构实现——概念少,覆盖广,变化不扩散。
|
||||
---
|
||||
## 一、模块边界清晰
|
||||
每个模块只做一件事,依赖方向稳定,指向抽象而非细节。改 A 不需要连带动 B、C、D,没有循环依赖,没有到处互相 import。
|
||||
## 二、局部可推理
|
||||
看一个文件、一个函数,就能判断它的行为、代价和失败模式。不需要全局搜索隐式约定、全局状态或魔法配置才能读懂。
|
||||
## 三、可组合
|
||||
小组件能自然地组合成大能力,接口一致、正交,不需要为组合写特例。"这个只能在那种情况下用"是坏信号。
|
||||
## 四、变化半径小
|
||||
改一个需求,改动集中、可预测,回归风险可控。而不是改动像地震,到处打补丁,回归测试覆盖不了心里的担忧。
|
||||
## 五、复杂度线性增长
|
||||
新增一个功能,新增代码量近似线性,重复少。而不是功能越多代码越膨胀,出现大量相似分支和 if-else 森林。
|
||||
## 六、约束写进代码
|
||||
关键不变量和约束写进类型、接口、校验、状态机,错误尽早暴露。而不是靠调用者"记得要先做 X 再做 Y",靠注释和口口相传。
|
||||
## 七、可测试、可观测
|
||||
依赖可注入,单测容易写,日志和指标能定位因果链。而不是只能端到端测,一出问题就黑盒,难以复现。
|
||||
## 八、一致且不意外
|
||||
命名、错误处理、资源管理、并发模型全局一致,很少有反直觉的地方。而不是同一类问题三套解法,新人踩坑全靠运气。
|
||||
## 九、自解释,注释极简
|
||||
代码本身就是文档。命名、结构、流程足以说明意图,注释只出现在真正难以一眼看懂或容易误读的地方。如果一段代码需要大段注释才能读懂,说明代码本身该重写。
|
||||
## 十、代码极简,视觉均匀
|
||||
行数尽量少,不写多余的代码。每行长度大致平均,避免忽长忽短的锯齿感。简洁不是压缩,是没有废话。
|
||||
## 十一、函数式倾向,减少副作用
|
||||
优先纯函数,输入决定输出,减少隐藏的状态突变。但不教条——如果一个全局变量能显著降低整体复杂度,接受它。目标是整体简单,不是局部纯粹。
|
||||
## 十二、功能越多,代码应该越短
|
||||
好的抽象让新功能复用已有结构,而不是堆砌新代码。功能翻倍但代码量没怎么涨,说明抽象到位了。反过来,功能越加代码越膨胀,是架构在退化。
|
||||
## 十三、为未来的接入性设计
|
||||
写代码时设想:这段逻辑未来会被别的模块调用吗?会被外部系统接入吗?好代码天然留有干净的调用入口,而不是写死在某个特定场景里,等需要复用时才发现得大改。
|
||||
## 十四、Let it crash——按失败半径决定防御策略
|
||||
半径大的错误显式报错、快速中断;半径为零的静默放过。不分轻重地到处 try-catch,反而把真正需要暴露的问题吞掉了。
|
||||
## 十五、篇幅分布跟着功能分布走
|
||||
一个函数里,主功能占大部分代码,兜底和错误处理压到最短。如果防御性代码比正事还长,说明结构有问题。读代码应该一眼看出主线,而不是在 fallback 里找。
|
||||
---
|
||||
## 快速自检
|
||||
拿到一段代码,问自己四个问题:
|
||||
1. **我能不能不看全局,就安全地改一个局部?**
|
||||
2. **有没有一个清晰的核心抽象,让新功能主要是"加新实现"而不是"改旧逻辑"?**
|
||||
3. **变化点是收敛在边界上,还是散落在各处?**
|
||||
4. **出故障时,能快速定位到责任模块,还是全员背锅?**
|
||||
四个问题都能干脆地答"是",就是好代码。
|
||||
@@ -13,7 +13,7 @@
|
||||
1. **无SOP时**:根据任务性质和进度,预估未来会遇到的关键风险点
|
||||
2. **启动subagent**(cwd=代码根):
|
||||
```
|
||||
python agentmain.py --task {name} --bg --verbose
|
||||
python agentmain.py --task {name} --verbose
|
||||
```
|
||||
input.txt:`用{SOP名}完成{用户任务}`(只给目标,不复述步骤)
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ CONTINUATION_PROMPT = """[Goal Mode — 持续推进]
|
||||
2. 在 cwd 下建立工作文件夹存放成果和进度,复杂任务可使用 plan 模式。
|
||||
3. 如果当前方向做完了,主动找下一个改进点:测试/边界case/性能/安全/文档/代码质量。
|
||||
4. 找不到改进点?扩大视野:关联模块、上下游依赖、用户体验、错误提示、日志可观测性、上网搜索、找其他路径、翻记忆里面有无相关。
|
||||
5. 要为了目标持续推进,在工作文件夹中记录进度,不要更新全局记忆。
|
||||
"""
|
||||
|
||||
BUDGET_LIMIT_PROMPT = """[Goal Mode — 预算耗尽,收口]
|
||||
|
||||
Reference in new issue
Block a user