feat: agent BBS server + team worker + timeout bump
This commit is contained in:
1 parent
e08d7c7370
commit
edbc473660
4 files changed
+259
-1
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
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)
|
||||
@@ -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,可以一定程度信任
|
||||
"""
|
||||
Reference in new issue
Block a user