diff --git a/agents/luna/api_server.py b/agents/luna/api_server.py index 2c68f19..7710fb8 100644 --- a/agents/luna/api_server.py +++ b/agents/luna/api_server.py @@ -45,8 +45,12 @@ from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactServ from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService from agents.luna.app import dev_app +# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖 +import gateway_client + # 配置 HOST = os.getenv("API_SERVER_HOST", "0.0.0.0") @@ -82,6 +86,9 @@ def create_api_server() -> ApiServer: # 认证服务(暂不需要,内存版占位) credential_service = InMemoryCredentialService() + # 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆) + memory_service = InMemoryMemoryService() + # 评测集管理(暂不需要,占位) eval_sets_manager = InMemoryEvalSetsManager() eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR) @@ -89,6 +96,7 @@ def create_api_server() -> ApiServer: return ApiServer( agent_loader=DevAgentLoader(), session_service=session_service, + memory_service=memory_service, artifact_service=artifact_service, credential_service=credential_service, eval_sets_manager=eval_sets_manager, @@ -102,6 +110,13 @@ def main(): api_server = create_api_server() fastapi_app = api_server.get_fast_api_app() + # 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动) + gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}") + if gateway_ok: + gateway_client.start_heartbeat(dev_app.name) + else: + print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)") + print("=" * 60) print("Luna Agent API Server 启动中...") print(f" 模型: {dev_app.root_agent.model.model}") diff --git a/agents/my_agent/agent_restart.err.log b/agents/my_agent/agent_restart.err.log new file mode 100644 index 0000000..742e27c --- /dev/null +++ b/agents/my_agent/agent_restart.err.log @@ -0,0 +1,21 @@ +C:\Users\nzy\AppData\Local\Programs\Python\Python314\Lib\site-packages\google\adk\features\_feature_decorator.py:72: UserWarning: [EXPERIMENTAL] feature FeatureName.PLUGGABLE_AUTH is enabled. + check_feature_enabled() +D:\nzy\workspace_python\agent\agents\my_agent\app.py:11: UserWarning: [EXPERIMENTAL] EventsCompactionConfig: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time. + compaction_config = EventsCompactionConfig( +D:\nzy\workspace_python\agent\agents\my_agent\api_server.py:86: UserWarning: [EXPERIMENTAL] InMemoryCredentialService: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time. + credential_service = InMemoryCredentialService() +C:\Users\nzy\AppData\Local\Programs\Python\Python314\Lib\site-packages\google\adk\auth\credential_service\in_memory_credential_service.py:33: UserWarning: [EXPERIMENTAL] BaseCredentialService: This feature is experimental and may change or be removed in future versions without notice. It may introduce breaking changes at any time. + super().__init__() +Traceback (most recent call last): + File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 134, in + main() + ~~~~^^ + File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 105, in main + api_server = create_api_server() + File "D:\nzy\workspace_python\agent\agents\my_agent\api_server.py", line 92, in create_api_server + return ApiServer( + agent_loader=DevAgentLoader(), + ...<6 lines>... + auto_create_session=True, + ) +TypeError: ApiServer.__init__() missing 1 required keyword-only argument: 'memory_service' diff --git a/agents/my_agent/agent_restart.log b/agents/my_agent/agent_restart.log new file mode 100644 index 0000000..e69de29 diff --git a/agents/my_agent/api_server.py b/agents/my_agent/api_server.py index c51e3e0..8add327 100644 --- a/agents/my_agent/api_server.py +++ b/agents/my_agent/api_server.py @@ -45,8 +45,12 @@ from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactServ from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService from agents.my_agent.app import dev_app +# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖 +import gateway_client + # 配置 HOST = os.getenv("API_SERVER_HOST", "0.0.0.0") @@ -82,6 +86,9 @@ def create_api_server() -> ApiServer: # 认证服务(暂不需要,内存版占位) credential_service = InMemoryCredentialService() + # 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆) + memory_service = InMemoryMemoryService() + # 评测集管理(暂不需要,占位) eval_sets_manager = InMemoryEvalSetsManager() eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR) @@ -89,6 +96,7 @@ def create_api_server() -> ApiServer: return ApiServer( agent_loader=DevAgentLoader(), session_service=session_service, + memory_service=memory_service, artifact_service=artifact_service, credential_service=credential_service, eval_sets_manager=eval_sets_manager, @@ -102,6 +110,17 @@ def main(): api_server = create_api_server() fastapi_app = api_server.get_fast_api_app() + # 挂载 A2A 网关任务接收端点(POST /tasks/{request_id}) + from agents.my_agent.task_receiver import router as task_router + fastapi_app.include_router(task_router) + + # 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动) + gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}") + if gateway_ok: + gateway_client.start_heartbeat(dev_app.name) + else: + print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)") + print("=" * 60) print("Dev Agent API Server 启动中...") print(f" 监听地址: http://{HOST}:{PORT}") diff --git a/agents/my_agent/task_receiver.py b/agents/my_agent/task_receiver.py new file mode 100644 index 0000000..fee0742 --- /dev/null +++ b/agents/my_agent/task_receiver.py @@ -0,0 +1,120 @@ +"""A2A 网关任务接收端点:接收网关主动推送的任务,后台执行 agent,完成后回传结果。 + +契约(网关 relay.py dispatch_command 推送): + POST {endpoint}/tasks/{request_id} + body: {"auth": GATEWAY_AUTH, "request_id": str, "payload": {...}} + 成功响应 202(立即确认),执行完成后由后台线程回传网关 /api/agent/result。 +""" +import asyncio +import logging +import threading + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse + +import gateway_client +from agents.my_agent.app import dev_app +from google.adk.runners import InMemoryRunner +from google.genai import types as genai_types + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["tasks"]) + +# 与 api_server.py 一致的导入路径(独立运行时兜底) +import os +import sys + +PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + +def _payload_to_prompt(payload: dict) -> str: + """将网关任务 payload 转换为 agent 的用户指令。""" + if not payload: + return "请执行任务并汇报结果。" + if "prompt" in payload and payload["prompt"]: + return str(payload["prompt"]) + if "cmd" in payload and payload["cmd"]: + return f"请执行以下命令并汇报执行结果:\n{payload['cmd']}" + # 兜底:序列化整个 payload + return "请根据以下任务载荷执行并汇报结果:\n" + str(payload) + + +async def _run_agent_once(prompt: str, request_id: str) -> tuple[str, str]: + """用 ADK InMemoryRunner 运行一次 agent,返回 (接受任务后的首条回复, 最终总结)。 + + dev_app 是 App 容器(根 agent 非裸 LlmAgent),run_async 不会自动创建 + session,需先用 runner.session_service 显式创建。 + """ + runner = InMemoryRunner(app=dev_app) + session_id = f"task-{request_id}" + await runner.session_service.create_session( + app_name=runner.app_name, + user_id="gateway", + session_id=session_id, + ) + texts: list[str] = [] + final_text = "" + async for event in runner.run_async( + user_id="gateway", + session_id=session_id, + new_message=genai_types.Content(role="user", parts=[genai_types.Part(text=prompt)]), + ): + if event.is_final_response(): + if event.content and event.content.parts: + for part in event.content.parts: + text = getattr(part, "text", None) + if text: + final_text += text + break + if event.content and event.content.parts: + for part in event.content.parts: + text = getattr(part, "text", None) + if text: + texts.append(text) + # 首条回复 = 接受任务后的第一条回应;最终总结 = final response + reply = (texts[0] if texts else final_text).strip() + summary = final_text.strip() or "\n".join(t for t in texts if t).strip() or "(无输出)" + return reply, summary + + +def _execute_and_report(request_id: str, payload: dict) -> None: + """后台线程:执行 agent,成功后回传 success,异常回传 failed。""" + try: + prompt = _payload_to_prompt(payload) + reply, summary = asyncio.run(_run_agent_once(prompt, request_id)) + gateway_client.report_result( + request_id, + agent_id=dev_app.name, + status="success", + progress=100, + result={"reply": reply, "output": summary}, + ) + except Exception as e: + logger.exception("agent task failed request=%s", request_id) + gateway_client.report_result( + request_id, + agent_id=dev_app.name, + status="failed", + progress=100, + error_info=str(e), + ) + + +@router.post("/tasks/{request_id}") +async def receive_task(request_id: str, request: Request): + """接收网关推送的任务,立即 202 确认,后台执行。""" + body = await request.json() + if body.get("auth") != gateway_client.GATEWAY_AUTH: + raise HTTPException(status_code=401, detail="invalid auth") + payload = body.get("payload") or {} + threading.Thread( + target=_execute_and_report, + args=(request_id, payload), + name=f"task-{request_id[:8]}", + daemon=True, + ).start() + logger.info("task received request=%s payload=%s", request_id, payload) + return JSONResponse(status_code=202, content={"ok": True, "request_id": request_id, "status": "accepted"}) diff --git a/agents/qwen/api_server.py b/agents/qwen/api_server.py index c7dc173..5ab290e 100644 --- a/agents/qwen/api_server.py +++ b/agents/qwen/api_server.py @@ -45,8 +45,12 @@ from google.adk.artifacts.in_memory_artifact_service import InMemoryArtifactServ from google.adk.auth.credential_service.in_memory_credential_service import InMemoryCredentialService from google.adk.evaluation.in_memory_eval_sets_manager import InMemoryEvalSetsManager from google.adk.evaluation.local_eval_set_results_manager import LocalEvalSetResultsManager +from google.adk.memory.in_memory_memory_service import InMemoryMemoryService from agents.qwen.app import dev_app +# A2A 网关接入(注册 + 心跳),放最底部 import 以免循环依赖 +import gateway_client + # 配置 HOST = os.getenv("API_SERVER_HOST", "0.0.0.0") @@ -82,6 +86,9 @@ def create_api_server() -> ApiServer: # 认证服务(暂不需要,内存版占位) credential_service = InMemoryCredentialService() + # 记忆服务(内存版占位:满足 ApiServer 必填参数,不含记忆工具/回调,不会注入记忆) + memory_service = InMemoryMemoryService() + # 评测集管理(暂不需要,占位) eval_sets_manager = InMemoryEvalSetsManager() eval_set_results_manager = LocalEvalSetResultsManager(agents_dir=DATA_DIR) @@ -89,6 +96,7 @@ def create_api_server() -> ApiServer: return ApiServer( agent_loader=DevAgentLoader(), session_service=session_service, + memory_service=memory_service, artifact_service=artifact_service, credential_service=credential_service, eval_sets_manager=eval_sets_manager, @@ -102,6 +110,13 @@ def main(): api_server = create_api_server() fastapi_app = api_server.get_fast_api_app() + # 向 A2A 网关注册并启动心跳(注册失败不阻塞服务启动) + gateway_ok = gateway_client.register_agent(dev_app.name, f"http://127.0.0.1:{PORT}") + if gateway_ok: + gateway_client.start_heartbeat(dev_app.name) + else: + print("[gateway] 注册失败,跳过心跳(网关可能未启动或 auth 不对)") + print("=" * 60) print("Qwen Agent API Server 启动中...") print(f" 模型: {dev_app.root_agent.model.model}") diff --git a/create_agent.py b/create_agent.py new file mode 100644 index 0000000..43c6cda --- /dev/null +++ b/create_agent.py @@ -0,0 +1,196 @@ +""" +快速创建新 Agent 脚本 +从 luna 模板复制出一个新 agent 目录,自动替换所有唯一标识符,并: +- 生成 agents// 下的 agent.py / app.py / api_server.py / chat.py / .env / __init__.py +- 生成 mcp_dev_agent/_server.py 入口 +- 更新 C:/Users/nzy/.codebuddy/.mcp.json 追加 MCP server 条目 + +命名约定(沿用 luna/qwen): + DIR = 用户输入(如 claude) + AGENT = {DIR}_agent (App name / agent name) + DB = sessions_{DIR}.db + MCP = kebab-case(如 claude-agent) + +用法: + python create_agent.py claude --model opcode/claude-sonnet + python create_agent.py claude --model opcode/claude-sonnet --port 8005 --display "Claude Dev Agent" +""" +import argparse +import json +import os +import re +import shutil +import sys + +# 强制 UTF-8(Windows 控制台默认 GBK,无法打印 ✓/→ 等字符) +if sys.platform == "win32": + os.environ.setdefault("PYTHONUTF8", "1") + try: + sys.stdout.reconfigure(encoding="utf-8") + sys.stderr.reconfigure(encoding="utf-8") + except Exception: + pass + +# 仓库根目录 +REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) +AGENTS_DIR = os.path.join(REPO_ROOT, "agents") +MCP_DIR = os.path.join(REPO_ROOT, "mcp_dev_agent") +TEMPLATE_DIR = os.path.join(AGENTS_DIR, "luna") +MCP_CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".codebuddy", ".mcp.json") +VENV_PYTHON = os.path.join(REPO_ROOT, ".venv", "Scripts", "python.exe") + +# 模板中需要替换的标识符(按顺序执行,先长后短避免误伤) +REPLACEMENTS = [ + ("luna_agent", "{AGENT}"), # 覆盖所有 app/agent name 引用 + ("luna", "{DIR}"), # 剩余:模块路径、db 名、instruction 内称呼 + ("Luna", "{TITLE}"), # 首字母大写:Luna Agent / Luna: / === Luna + ("8002", "{PORT}"), # 默认端口 + ("opcode/deepseek-v4-flash", "{MODEL}"), # .env 的 VLLM_MODEL +] + +# 复制时排除的目录/文件 +SKIP_DIR_NAMES = {"__pycache__", ".adk", ".git", ".idea"} +SKIP_FILENAMES = {".gitignore"} + + +def interact(arg_dir: str, model: str, port: int, display: str) -> tuple[str, str, int, str]: + """补齐缺失参数(缺省时交互式询问)""" + d = arg_dir + if not d: + d = input("Agent 目录名(如 claude): ").strip() + if not model: + model = input("模型名(如 opcode/claude-sonnet): ").strip() + if port is None: + port = auto_next_port() + if not display: + display = f"{d.title()} Agent ({model}) 全栈开发助手" + return d, model, port, display + + +def auto_next_port() -> int: + """扫描 agents/*/.env 的 API_SERVER_PORT,取最大值 +1""" + max_port = 8000 + if os.path.isdir(AGENTS_DIR): + for entry in os.listdir(AGENTS_DIR): + env_path = os.path.join(AGENTS_DIR, entry, ".env") + if os.path.isfile(env_path): + m = re.search(r"API_SERVER_PORT\s*=\s*(\d+)", open(env_path, encoding="utf-8").read()) + if m: + max_port = max(max_port, int(m.group(1))) + return max_port + 1 + + +def copy_tree(src: str, dst: str) -> None: + """递归复制,跳过 __pycache__/.adk/.git 等""" + os.makedirs(dst, exist_ok=True) + for name in os.listdir(src): + s = os.path.join(src, name) + d = os.path.join(dst, name) + if os.path.isdir(s): + if name in SKIP_DIR_NAMES: + continue + copy_tree(s, d) + else: + if name in SKIP_FILENAMES: + continue + shutil.copy2(s, d) + + +def apply_replacements(path: str, mapping: dict) -> None: + """对文件内容按顺序做字符串替换""" + with open(path, encoding="utf-8") as f: + content = f.read() + for old, key in REPLACEMENTS: + content = content.replace(old, mapping.get(key, "")) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +def update_mcp_config(mcp_key: str, server_file: str, display: str) -> None: + """在 .mcp.json 的 mcpServers 中追加一条""" + if not os.path.isfile(MCP_CONFIG_PATH): + print(f"[warn] 未找到 {MCP_CONFIG_PATH},跳过 MCP 配置更新") + return + + with open(MCP_CONFIG_PATH, encoding="utf-8") as f: + cfg = json.load(f) + + if mcp_key in cfg.get("mcpServers", {}): + print(f"[warn] .mcp.json 已存在 {mcp_key} 条目,跳过") + return + + cfg.setdefault("mcpServers", {})[mcp_key] = { + "type": "stdio", + "command": VENV_PYTHON, + "args": [server_file], + "description": display, + } + + with open(MCP_CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2) + + print(f" ✓ 已更新 {MCP_CONFIG_PATH}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="创建一个新的 Dev Agent(从 luna 模板)") + parser.add_argument("dir", nargs="?", help="agent 目录名(如 claude)") + parser.add_argument("--model", default=None, help="模型名(如 opcode/claude-sonnet)") + parser.add_argument("--port", type=int, default=None, help="API 端口,默认自动取最大+1") + parser.add_argument("--display", default=None, help="MCP 描述,默认 '{Dir} Agent ({model}) 全栈开发助手'") + args = parser.parse_args() + + d, model, port, display = interact(args.dir, args.model, args.port, args.display) + + if not d or not model: + print("错误:目录名和模型名不能为空") + sys.exit(1) + + agent = f"{d}_agent" + title = d.title() + db = f"sessions_{d}.db" + mcp_key = f"{d.replace('_', '-')}-agent" + target_dir = os.path.join(AGENTS_DIR, d) + server_file = os.path.join(MCP_DIR, f"{d}_server.py") + + mapping = {"{AGENT}": agent, "{DIR}": d, "{TITLE}": title, + "{PORT}": str(port), "{MODEL}": model} + + # 1. 校验目标目录不存在 + if os.path.exists(target_dir): + print(f"错误:目录已存在 {target_dir}") + sys.exit(1) + if os.path.exists(server_file): + print(f"错误:MCP server 已存在 {server_file}") + sys.exit(1) + + print(f"创建 Agent: {d} (agent={agent}, port={port}, model={model})") + print(f" 数据库: {db}") + + # 2. 复制模板目录 + copy_tree(TEMPLATE_DIR, target_dir) + # 3. 替换所有文件里的标识符 + for root, _dirs, files in os.walk(target_dir): + for fn in files: + apply_replacements(os.path.join(root, fn), mapping) + # 4. 重写 __init__.py + with open(os.path.join(target_dir, "__init__.py"), "w", encoding="utf-8") as f: + f.write(f"# {d} package\nfrom . import agent\n") + print(f" ✓ 已生成 agents/{d}/") + + # 5. 生成 MCP server 入口 + shutil.copy2(os.path.join(MCP_DIR, "luna_server.py"), server_file) + apply_replacements(server_file, mapping) + print(f" ✓ 已生成 {server_file}") + + # 6. 更新 .mcp.json + update_mcp_config(mcp_key, server_file, display) + + print("\n完成!启动方式:") + print(f" cd agents/{d} && python api_server.py # 启动 API Server(自动注册到网关)") + print(f" cd agents/{d} && python chat.py # 命令行对话") + print(f" python agent_status.py --agent {agent} # 查看状态(需先手动加入 AGENTS 映射)") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gateway_client.py b/gateway_client.py new file mode 100644 index 0000000..f7f5c18 --- /dev/null +++ b/gateway_client.py @@ -0,0 +1,140 @@ +"""A2A 网关接入客户端:Agent 服务启动后自动注册 + 心跳保活 + 注销。 + +用法(在 api_server.py 的 main() 中): + from gateway_client import register_agent, start_heartbeat, unregister_agent + + if register_agent(dev_app.name, f"http://127.0.0.1:{PORT}"): + start_heartbeat(dev_app.name) + +环境变量: + GATEWAY_URL 网关地址,默认 http://127.0.0.1:8000 + GATEWAY_AUTH 网关认证密码,需与网关 GATEWAY_AUTH 一致 + AGENT_TAGS 能力标签,逗号分隔,默认 code + AGENT_MAX_CONCURRENT 最大并发数,默认 1 + HEARTBEAT_INTERVAL 心跳间隔秒,默认 10 +""" +import logging +import os +import threading +import time + +import httpx + +logger = logging.getLogger(__name__) + +GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8000").rstrip("/") +GATEWAY_AUTH = os.getenv("GATEWAY_AUTH", "dev-gateway-auth") +HEARTBEAT_INTERVAL = int(os.getenv("HEARTBEAT_INTERVAL", "10")) + +_client = httpx.Client(timeout=5.0) + + +def _parse_tags() -> list[str]: + raw = os.getenv("AGENT_TAGS", "code") + return [t.strip() for t in raw.split(",") if t.strip()] + + +def register_agent(agent_id: str, endpoint: str, agent_tags: list[str] | None = None, + max_concurrent: int | None = None) -> bool: + """向网关注册本 Agent。成功返回 True,失败(网关未启动/认证失败)返回 False。""" + tags = agent_tags if agent_tags is not None else _parse_tags() + max_conc = max_concurrent or int(os.getenv("AGENT_MAX_CONCURRENT", "1")) + body = { + "auth": GATEWAY_AUTH, + "agent_id": agent_id, + "endpoint": endpoint, + "agent_tags": tags, + "max_concurrent": max_conc, + "current_load": 0, + } + try: + resp = _client.post(f"{GATEWAY_URL}/api/agent/register", json=body) + if resp.status_code == 200: + logger.info("registered to gateway agent=%s endpoint=%s tags=%s", agent_id, endpoint, tags) + return True + logger.error("register failed agent=%s status=%s body=%s", agent_id, resp.status_code, resp.text) + except httpx.HTTPError as e: + logger.error("register network error agent=%s err=%s", agent_id, e) + return False + + +def _heartbeat_once(agent_id: str, current_load: int) -> bool: + try: + resp = _client.post( + f"{GATEWAY_URL}/api/agent/heartbeat", + json={"agent_id": agent_id, "current_load": current_load}, + ) + if resp.status_code == 200: + return True + logger.warning("heartbeat failed status=%s body=%s", resp.status_code, resp.text) + except httpx.HTTPError as e: + logger.warning("heartbeat network error err=%s", e) + return False + + +def _heartbeat_loop(agent_id: str) -> None: + while True: + try: + load = get_current_load() + except Exception: + load = 0 + _heartbeat_once(agent_id, load) + time.sleep(HEARTBEAT_INTERVAL) + + +def start_heartbeat(agent_id: str) -> threading.Thread: + """启动后台心跳线程(daemon,随进程退出)。""" + t = threading.Thread(target=_heartbeat_loop, args=(agent_id,), name=f"gateway-hb-{agent_id}", daemon=True) + t.start() + logger.info("heartbeat started agent=%s interval=%ss", agent_id, HEARTBEAT_INTERVAL) + return t + + +def unregister_agent(agent_id: str) -> bool: + """向网关注销本 Agent。""" + try: + resp = _client.post(f"{GATEWAY_URL}/api/agent/unregister", json={"agent_id": agent_id}) + if resp.status_code in (200, 404): + logger.info("unregistered from gateway agent=%s", agent_id) + return True + logger.warning("unregister failed status=%s body=%s", resp.status_code, resp.text) + except httpx.HTTPError as e: + logger.error("unregister network error agent=%s err=%s", agent_id, e) + return False + + +def report_result(request_id: str, agent_id: str, status: str = "success", + progress: int = 100, result: dict | None = None, + error_info: str | None = None) -> bool: + """任务执行完成后,将结果回传给网关(POST /api/agent/result)。 + + Args: + request_id: 网关分配的任务 ID + agent_id: 本 Agent ID + status: success / failed + progress: 进度百分比 + result: 结果字典(可选) + error_info: 错误信息(失败时必填) + """ + body = { + "request_id": request_id, + "agent_id": agent_id, + "status": status, + "progress": progress, + "result": result, + "error_info": error_info, + } + try: + resp = _client.post(f"{GATEWAY_URL}/api/agent/result", json=body) + if resp.status_code == 200: + logger.info("result reported request=%s status=%s", request_id, status) + return True + logger.error("report result failed request=%s status=%s body=%s", request_id, resp.status_code, resp.text) + except httpx.HTTPError as e: + logger.error("report result network error request=%s err=%s", request_id, e) + return False + + +def get_current_load() -> int: + """Agent 当前负载。子类/调用方可覆写以报告真实并发数。""" + return 0