From edbc473660cb4860c09a10213656287e123cc32d Mon Sep 17 00:00:00 2001 From: Liang Jiaqing Date: Wed, 6 May 2026 18:54:59 +0800 Subject: [PATCH] feat: agent BBS server + team worker + timeout bump --- .gitignore | 1 + agentmain.py | 2 +- assets/agent_bbs.py | 215 +++++++++++++++++++++++++++++++++++ reflect/agent_team_worker.py | 42 +++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 assets/agent_bbs.py create mode 100644 reflect/agent_team_worker.py diff --git a/.gitignore b/.gitignore index 0e4180eb..76d95cec 100644 --- a/.gitignore +++ b/.gitignore @@ -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__/ diff --git a/agentmain.py b/agentmain.py index 192ed3ae..8645ca21 100644 --- a/agentmain.py +++ b/agentmain.py @@ -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: diff --git a/assets/agent_bbs.py b/assets/agent_bbs.py new file mode 100644 index 00000000..50e52ab7 --- /dev/null +++ b/assets/agent_bbs.py @@ -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 = """ +Agent BBS + +

Agent BBS

+
+ + + + +
+
+""" + +README_TEXT = "Agent BBS API\tAuth: ALL requests require header X-API-Key: or pass ?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) \ No newline at end of file diff --git a/reflect/agent_team_worker.py b/reflect/agent_team_worker.py new file mode 100644 index 00000000..d4cce62f --- /dev/null +++ b/reflect/agent_team_worker.py @@ -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,可以一定程度信任 +"""