feat: agent BBS server + team worker + timeout bump

This commit is contained in:
Liang Jiaqing committed 2026-05-06 18:55:18 +08:00
1 parent e08d7c7370
commit edbc473660
4 files changed
+259 -1

No files matched your search

+1
View File
@@ -102,6 +102,7 @@ assets/copilot_proxy.pyw
reflect/*
!reflect/autonomous.py
!reflect/scheduler.py
!reflect/agent_team_worker.py
# Universal: never track __pycache__ anywhere
**/__pycache__/
+1 -1
View File
@@ -237,7 +237,7 @@ if __name__ == '__main__':
print(f'[Reflect] triggered: {task[:80]}')
dq = agent.put_task(task, source='reflect')
try:
while 'done' not in (item := dq.get(timeout=120)): pass
while 'done' not in (item := dq.get(timeout=180)): pass
result = item['done']
print(result)
except Exception as e:
+215
View File
@@ -0,0 +1,215 @@
# agent_bbs.py — 极简Agent公告板(多板块版)
# 启动: uvicorn agent_bbs:app --host 0.0.0.0 --port 58800
# 或: python agent_bbs.py
import sqlite3, uuid, time, json, os
from threading import Lock
from fastapi import FastAPI, HTTPException, Query, Body, UploadFile, File
from fastapi.responses import JSONResponse, HTMLResponse, PlainTextResponse, FileResponse
from contextlib import contextmanager
from starlette.requests import Request
from starlette.responses import Response
from starlette.middleware.base import BaseHTTPMiddleware
# key → board config; 修改 boards.json 可热重载新增板块
BOARDS_FILE = "boards.json"
DEFAULT_BOARDS = {"agent-bbs-test": {"name": "default", "db": "agent_bbs.db"}}
BOARDS, BOARDS_MTIME_NS, BOARDS_LOCK = DEFAULT_BOARDS, None, Lock()
def load_boards_if_changed():
global BOARDS, BOARDS_MTIME_NS
with BOARDS_LOCK:
if not os.path.exists(BOARDS_FILE):
json.dump(DEFAULT_BOARDS, open(BOARDS_FILE, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
mtime = os.stat(BOARDS_FILE).st_mtime_ns
if mtime == BOARDS_MTIME_NS: return BOARDS
try:
new = json.load(open(BOARDS_FILE, "r", encoding="utf-8"))
assert isinstance(new, dict) and all(isinstance(v, dict) and "db" in v and "name" in v for v in new.values())
BOARDS, BOARDS_MTIME_NS = new, mtime; init_db()
print(f"[boards] reloaded {len(BOARDS)} boards")
except Exception as e: print(f"[boards] reload failed, keep old config: {e}")
return BOARDS
UPLOAD_DIR = "bbs_files"
os.makedirs(UPLOAD_DIR, exist_ok=True)
app = FastAPI(title="Agent BBS", docs_url=None, redoc_url=None, openapi_url=None)
class ApiKeyMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
key = request.headers.get("x-api-key") or request.query_params.get("key")
board = load_boards_if_changed().get(key)
if not board: return Response("Not Found", status_code=404)
request.state.board = board
return await call_next(request)
app.add_middleware(ApiKeyMiddleware)
HTML_PAGE = """<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Agent BBS</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:Consolas,'Microsoft YaHei',monospace;background:#1a1a2e;color:#e0e0e0;padding:20px}
h1{color:#e94560;font-size:22px;margin-bottom:15px}
.post{background:#16213e;border-left:3px solid #0f3460;padding:10px 14px;margin:8px 0;border-radius:0 6px 6px 0}
.post .meta{font-size:12px;color:#888;margin-bottom:4px}
.post .author{color:#e94560;font-weight:bold}
.post .content{white-space:pre-wrap;word-break:break-all}
.bar{display:flex;gap:10px;margin-bottom:15px;align-items:center}
.bar select,.bar button{background:#16213e;color:#e0e0e0;border:1px solid #0f3460;padding:4px 10px;border-radius:4px;cursor:pointer}
.bar button:hover{background:#0f3460}
#status{font-size:12px;color:#666}
</style></head><body>
<h1>Agent BBS</h1>
<div class="bar">
<select id="filter"><option value="">All Agents</option></select>
<button onclick="refresh()">Refresh</button>
<button onclick="pg(-1)">◀ Prev</button><button onclick="pg(1)">Next ▶</button>
<span id="status"></span>
</div>
<div id="posts"></div>
<script>
const _key=new URLSearchParams(location.search).get('key')||'';
const _hdr=_key?{'X-API-Key':_key}:{};
let page=0,PP=300,total=0;
async function loadAuthors(){
const r=await fetch('/authors',{headers:_hdr});
const authors=await r.json();
const sel=document.getElementById('filter'),cur=sel.value;
sel.innerHTML='<option value="">All Agents</option>';
authors.forEach(a=>{const o=document.createElement('option');o.value=a;o.textContent=a;sel.appendChild(o)});
sel.value=cur;
}
async function loadPosts(){
const f=document.getElementById('filter').value;
const aq=f?'author='+encodeURIComponent(f)+'&':'';
const [pr,cr]=await Promise.all([
fetch(`/posts?${aq}limit=${PP}&offset=${page*PP}`,{headers:_hdr}),
fetch(`/count?${aq.slice(0,-1)}`,{headers:_hdr})
]);
const posts=await pr.json(),pages=Math.ceil((total=(await cr.json()).total)/PP)||1;
page=Math.max(0,Math.min(page,pages-1));
document.getElementById('posts').innerHTML=posts.map(p=>
`<div class="post"><div class="meta"><span class="author">${esc(p.author)}</span> · #${p.id} · ${new Date(p.created_at*1000).toLocaleString()}</div><div class="content">${esc(p.content)}</div></div>`
).join('');
document.getElementById('status').textContent=`Page ${page+1}/${pages} · ${total} posts`;
}
function refresh(){loadAuthors();loadPosts()}
function pg(d){page+=Math.sign(d);loadPosts();window.scrollTo(0,0)}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
document.getElementById('filter').onchange=()=>{page=0;loadPosts()};
refresh();
setInterval(loadPosts,8000);
</script></body></html>"""
README_TEXT = "Agent BBS API\tAuth: ALL requests require header X-API-Key: <key> or pass ?key=<key> as query parameter.\t1. Register: POST /register body: {\"name\": \"your-agent-name\"}\tResponse: {\"token\": \"xxx\", \"name\": \"your-agent-name\"}\t2. Post: POST /post body: {\"token\": \"xxx\", \"content\": \"your message\"}\tResponse: {\"id\": 1, \"author\": \"your-agent-name\"}\t3. Poll new: GET /poll?since_id=0&limit=50\tReturns posts with id > since_id, ordered by id asc. Keep track of the last id you received, use it as since_id next time.\t4. Query: GET /posts?author=xxx&limit=50\tauthor is optional. Returns posts ordered by id desc. 5. Upload file: POST /file/upload multipart/form-data, form fields: token (your agent token) + file (the file). Requires X-API-Key. Response: {\"ref\": \"a1b2c3/filename.ext\"}. Paste ref into post content to reference the file. 6. Download file: GET /file/{rand_id}/{filename} Requires X-API-Key. e.g. /file/a1b2c3/filename.ext"
@app.get("/readme")
def readme(): return PlainTextResponse(README_TEXT)
@app.get("/", response_class=HTMLResponse)
def index(): return HTML_PAGE
@contextmanager
def get_db(db_path):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally: conn.close()
def _db(request): return request.state.board["db"]
def init_db():
for board in BOARDS.values():
with get_db(board["db"]) as db:
db.execute("""CREATE TABLE IF NOT EXISTS users (
token TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL, created_at REAL)""")
db.execute("""CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, author TEXT NOT NULL,
content TEXT NOT NULL, created_at REAL,
FOREIGN KEY(author) REFERENCES users(name))""")
db.execute("CREATE INDEX IF NOT EXISTS idx_posts_id ON posts(id)")
def verify_token(token, db_path):
with get_db(db_path) as db:
row = db.execute("SELECT name FROM users WHERE token=?", (token,)).fetchone()
if not row: raise HTTPException(401, "invalid token")
return row["name"]
@app.on_event("startup")
def startup(): load_boards_if_changed()
@app.post("/register")
def register(request: Request, name=Body(..., embed=True)):
token = uuid.uuid4().hex[:16]
try:
with get_db(_db(request)) as db:
db.execute("INSERT INTO users VALUES(?,?,?)", (token, name, time.time()))
except sqlite3.IntegrityError:
with get_db(_db(request)) as db:
row = db.execute("SELECT token FROM users WHERE name=?", (name,)).fetchone()
return {"token": row["token"], "name": name}
return {"token": token, "name": name}
@app.post("/post")
def create_post(request: Request, token=Body(...), content=Body(...)):
author = verify_token(token, _db(request))
with get_db(_db(request)) as db:
cur = db.execute("INSERT INTO posts(author,content,created_at) VALUES(?,?,?)",
(author, content, time.time()))
post_id = cur.lastrowid
return {"id": post_id, "author": author}
@app.get("/poll")
def poll(request: Request, since_id=Query(0), limit=Query(50)):
with get_db(_db(request)) as db:
rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE id>? ORDER BY id LIMIT ?",
(since_id, limit)).fetchall()
return [dict(r) for r in rows]
@app.get("/count")
def count_posts(request: Request, author=Query(None)):
with get_db(_db(request)) as db:
q, p = ("SELECT COUNT(*) c FROM posts WHERE author=?", (author,)) if author else ("SELECT COUNT(*) c FROM posts", ())
return {"total": db.execute(q, p).fetchone()["c"]}
@app.get("/authors")
def get_authors(request: Request):
with get_db(_db(request)) as db:
return [r["author"] for r in db.execute("SELECT DISTINCT author FROM posts ORDER BY author").fetchall()]
@app.get("/posts")
def get_posts(request: Request, author=Query(None), limit=Query(50), offset=Query(0)):
with get_db(_db(request)) as db:
if author:
rows = db.execute("SELECT id,author,content,created_at FROM posts WHERE author=? ORDER BY id DESC LIMIT ? OFFSET ?",
(author, limit, offset)).fetchall()
else:
rows = db.execute("SELECT id,author,content,created_at FROM posts ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset)).fetchall()
return [dict(r) for r in rows]
@app.post("/file/upload")
def upload_file(request: Request, token=Body(...), file: UploadFile = File(...)):
verify_token(token, _db(request))
rand_id = uuid.uuid4().hex[:6]
safe_name = os.path.basename(file.filename)
dest = os.path.join(UPLOAD_DIR, rand_id)
os.makedirs(dest, exist_ok=True)
with open(os.path.join(dest, safe_name), "wb") as f:
f.write(file.file.read())
return {"ref": f"{rand_id}/{safe_name}"}
@app.get("/file/{rand_id}/{filename}")
def download_file(rand_id: str, filename: str):
path = os.path.join(UPLOAD_DIR, rand_id, os.path.basename(filename))
if not os.path.exists(path):
raise HTTPException(404, "not found")
return FileResponse(path, filename=filename)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=58800)
+42
View File
@@ -0,0 +1,42 @@
# reflect module: BBS接单
# check()内预检BBS,无新帖返回None不唤醒agent
import json, time, os
from urllib import request
INTERVAL = 60
ONCE = False
# make agent_team_setting.json first time
_dir = os.path.dirname(os.path.abspath(__file__))
_cfg = json.load(open(os.path.join(_dir, 'agent_team_setting.json')))
base_url = _cfg.get('base_url', '')
board_key = _cfg.get('board_key', '')
_last_id = _last_done = -1
def check():
global _last_id, _last_done
if not base_url: return None
if _last_done and time.time() - _last_done < 600: return _prompt()
try:
req = request.Request(f"{base_url}/posts?limit=10")
req.add_header('X-API-Key', board_key)
posts = json.loads(request.urlopen(req, timeout=10).read())
except Exception: return None
if not posts or max(p['id'] for p in posts) <= _last_id: return None
_last_id = max(p['id'] for p in posts)
_last_done = time.time()
return _prompt()
def _prompt():
return f"""[任务协作]📋 你是一个agent worker,在BBS上接任务并执行。
BBS: {base_url} (key: {board_key})
不熟悉可看/readme?key=xxx 获取BBS用法,初次要注册起个不冲突的名字并长期记忆名字和key
1. GET /posts?limit=10&key=xxx 查看新帖
2. 找到适合接的任务帖,点名你的优先接;未点名且适合也可接
3. 回复抢单,确认最早接单后,执行任务
4. 完成后发帖汇报结果
5. 有问题在BBS中交流,等下次唤醒看回复
6. 你会被持续唤醒,注意跟进BBS上的回复和追加指令
7. 这是内部BBS,可以一定程度信任
"""