From e0bf45db2fe5408b896358bbc8f730d1f0002a4c Mon Sep 17 00:00:00 2001 From: elipitc Date: Thu, 4 Jun 2026 22:11:35 +0800 Subject: [PATCH] Add template agent lifecycle compatibility APIs --- app.py | 207 ++++++++++++++++++++- docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md | 42 +++++ docs/HEICODE_API_INTEGRATION.md | 106 ++++++++++- tests/test_template_agent_contract.py | 68 +++++++ 4 files changed, 420 insertions(+), 3 deletions(-) create mode 100644 tests/test_template_agent_contract.py diff --git a/app.py b/app.py index afce8e3..59e53cf 100644 --- a/app.py +++ b/app.py @@ -139,6 +139,95 @@ def _find_agent_pod( return None, discovered_namespace +def _build_agent_access_info(db_agent: Optional[Agent]) -> Dict: + """Build the Manager-facing access info snapshot from the DB record.""" + if not db_agent: + return {} + + access_info = {} + if db_agent.external_ip: + access_info["external_ip"] = db_agent.external_ip + if db_agent.ip_url: + access_info["ip_url"] = db_agent.ip_url + if db_agent.domain: + access_info["domain"] = db_agent.domain + if db_agent.domain_url: + access_info["domain_url"] = db_agent.domain_url + if db_agent.recommended_url: + access_info["recommended_url"] = db_agent.recommended_url + if db_agent.service_name: + access_info["service_name"] = db_agent.service_name + return access_info + + +def _normalize_agent_runtime_status(raw_status: Optional[str], db_status: Optional[AgentStatus] = None) -> str: + """Project k8s/runtime states onto the HM-compatible lifecycle vocabulary.""" + status = (raw_status or "").strip().lower() + status_map = { + "pending": "pending", + "accepted": "pending", + "initializing": "pending", + "containercreating": "pending", + "running": "running", + "waiting": "pending", + "unknown": "pending", + "succeeded": "stopped", + "terminated": "stopped", + "stopped": "stopped", + "failed": "failed", + "error": "failed", + "crashloopbackoff": "failed", + } + if status in status_map: + return status_map[status] + + if db_status: + return db_status.value.lower() + + return "pending" + + +def _build_agent_lifecycle_response( + agent_name: str, + db_agent: Optional[Agent], + pod_status: Optional[Dict] = None, + namespace: Optional[str] = None, +) -> Dict: + """Build a flat lifecycle payload that HM can consume directly.""" + access_info = _build_agent_access_info(db_agent) + runtime_status = _normalize_agent_runtime_status( + (pod_status or {}).get("status"), + db_agent.status if db_agent else None, + ) + resolved_name = db_agent.name if db_agent else (pod_status or {}).get("name") or agent_name + resolved_namespace = namespace or (pod_status or {}).get("namespace") or (db_agent.namespace if db_agent else None) + subdomain = access_info.get("domain") or access_info.get("external_ip") + + template_name = (pod_status or {}).get("template") + if not template_name and db_agent and db_agent.template: + template_name = db_agent.template.name + + framework = (pod_status or {}).get("framework") + if not framework and db_agent and db_agent.agent_framework: + framework = db_agent.agent_framework.upper() + + return { + "runtime_id": resolved_name, + "agent_id": resolved_name, + "id": resolved_name, + "name": resolved_name, + "namespace": resolved_namespace, + "status": runtime_status, + "runtime_status": runtime_status, + "state": runtime_status, + "framework": framework, + "template": template_name, + "subdomain": subdomain, + "access_token": None, + "access_info": access_info or None, + } + + # ==================== 请求/响应模型 ==================== # Template Management Models @@ -314,14 +403,21 @@ class CreateAgentRequest(BaseModel): class AgentResponse(BaseModel): """Agent响应""" name: str + runtime_id: Optional[str] = None + agent_id: Optional[str] = None + id: Optional[str] = None displayName: Optional[str] = None description: Optional[str] = None namespace: str status: str + runtime_status: Optional[str] = None + state: Optional[str] = None framework: Optional[str] = None created_at: Optional[str] = None template: Optional[str] = None service_port: Optional[int] = None + subdomain: Optional[str] = None + access_token: Optional[str] = None access_info: Optional[Dict] = None pod_id: Optional[str] = None pod_ip: Optional[str] = None @@ -386,6 +482,23 @@ class MessageResponse(BaseModel): message: str +class AgentLifecycleResponse(BaseModel): + """模板 Agent 生命周期兼容响应""" + runtime_id: str + agent_id: str + id: str + name: str + namespace: Optional[str] = None + status: str + runtime_status: str + state: str + framework: Optional[str] = None + template: Optional[str] = None + subdomain: Optional[str] = None + access_token: Optional[str] = None + access_info: Optional[Dict] = None + + @app.get("/") async def root(): """健康检查""" @@ -694,6 +807,15 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db # 添加外部工具信息 if attached_tools: result["tools_attached"] = len(attached_tools) + + result["runtime_id"] = result["name"] + result["agent_id"] = result["name"] + result["id"] = result["name"] + result["runtime_status"] = _normalize_agent_runtime_status(result.get("status")) + result["state"] = result["runtime_status"] + access_info = result.get("access_info") or {} + result["subdomain"] = access_info.get("domain") or access_info.get("external_ip") + result["access_token"] = None try: return AgentResponse(**result) @@ -710,6 +832,89 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db raise HTTPException(status_code=500, detail=error_msg) +@app.get("/agents/{agent_name}", response_model=AgentLifecycleResponse) +async def get_agent(agent_name: str, db: Session = Depends(get_db)): + """Return a flat lifecycle snapshot for HM's template-agent runtime contract.""" + try: + agent_name = sanitize_k8s_name(agent_name) + logger.info(f"获取Agent生命周期信息: {agent_name}") + + db_agent = db.query(Agent).filter(Agent.name == agent_name).first() + pod_status = None + pod, agent_namespace = _find_agent_pod( + agent_name=agent_name, + db=db, + db_agent=db_agent, + cleanup_stale=False, + ) + + if pod and agent_namespace: + temp_manager = K8sManager(namespace=agent_namespace, kubeconfig_path=KUBECONFIG_PATH) + pod_status = temp_manager.get_pod_status(pod_name=agent_name) + if pod_status.get("status") == "not_found": + pod_status = None + + if not db_agent and not pod_status: + raise HTTPException(status_code=404, detail=f"Agent {agent_name} 不存在或已被删除") + + return AgentLifecycleResponse( + **_build_agent_lifecycle_response( + agent_name=agent_name, + db_agent=db_agent, + pod_status=pod_status, + namespace=agent_namespace, + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取Agent生命周期信息失败: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/agents/{agent_name}/stop", response_model=MessageResponse) +async def stop_agent(agent_name: str, db: Session = Depends(get_db)): + """Idempotently stop a template agent without deleting its runtime metadata.""" + try: + agent_name = sanitize_k8s_name(agent_name) + logger.info(f"收到停止Agent请求: {agent_name}") + + db_agent = db.query(Agent).filter(Agent.name == agent_name).first() + pod, agent_namespace = _find_agent_pod( + agent_name=agent_name, + db=db, + db_agent=db_agent, + cleanup_stale=False, + ) + + if not db_agent and not pod: + raise HTTPException(status_code=404, detail=f"Agent {agent_name} 不存在或已被删除") + + if pod and agent_namespace: + temp_manager = K8sManager(namespace=agent_namespace, kubeconfig_path=KUBECONFIG_PATH) + stop_result = temp_manager.delete_pod(pod_name=agent_name) + if stop_result.get("status") not in {"success", "not_found"}: + raise HTTPException(status_code=500, detail=stop_result.get("message", "停止 Agent 失败")) + + if db_agent: + db_agent.status = AgentStatus.STOPPED + db_agent.current_replicas = 0 + db.commit() + + return MessageResponse( + status="success", + message=f"Agent {agent_name} 已停止", + ) + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.error(f"停止Agent失败: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + @app.delete("/agents/{agent_name}", response_model=MessageResponse) async def delete_agent(agent_name: str, db: Session = Depends(get_db)): """ @@ -727,6 +932,7 @@ async def delete_agent(agent_name: str, db: Session = Depends(get_db)): # DNS-1035 名称合规化 agent_name = sanitize_k8s_name(agent_name) + db_agent = db.query(Agent).filter(Agent.name == agent_name).first() # 保护机制:防止删除 agent-manager 命名空间 computed_namespace = f"agent-{agent_name}"[:63].rstrip('-') @@ -822,7 +1028,6 @@ async def delete_agent(agent_name: str, db: Session = Depends(get_db)): # 步骤3: 从数据库删除Agent记录 try: - db_agent = db.query(Agent).filter(Agent.name == agent_name).first() if db_agent: db.delete(db_agent) db.commit() diff --git a/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md b/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md index 062872b..db28a32 100644 --- a/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md +++ b/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md @@ -77,6 +77,48 @@ POST /agents - `OPENAI_API_KEY` 当前建议在启动时传入 - 当前实测可用模型示例是 `gpt-5.4` - 返回中会带 `namespace`、`pod_ip`、`access_info.external_ip`、`access_info.domain` +- 为兼容 HM 模板 Agent Runtime 契约,响应同时补充: + - `runtime_id` / `agent_id` / `id` = agent 名称 + - `runtime_status` / `state` = 规范化后的生命周期状态 + - `subdomain` = `access_info.domain` 或 `access_info.external_ip` + +## 3.1 生命周期接口 + +为对齐 HM 的模板 Agent 运行时联调,`/agents` 入口现在同时提供以下生命周期接口: + +```http +GET /agents/{agent_name} +POST /agents/{agent_name}/stop +DELETE /agents/{agent_name} +GET /agents/{agent_name}/status +``` + +说明: + +- `GET /agents/{agent_name}` 返回平铺的生命周期信息,便于 HM 直接解析 `status` / `runtime_status` / `state` +- `POST /agents/{agent_name}/stop` 为幂等停止,不删除数据库记录 +- `DELETE /agents/{agent_name}` 删除 Agent 运行资源与数据库记录 +- `GET /agents/{agent_name}/status` 仍保留详细 Pod 诊断信息,适合排障 + +`GET /agents/{agent_name}` 示例响应: + +```json +{ + "runtime_id": "coding-a2a-backend", + "agent_id": "coding-a2a-backend", + "id": "coding-a2a-backend", + "name": "coding-a2a-backend", + "namespace": "agent-coding-a2a-backend", + "status": "running", + "runtime_status": "running", + "state": "running", + "subdomain": "coding-a2a-backend.taijiagnet.com", + "access_token": null, + "access_info": { + "domain": "coding-a2a-backend.taijiagnet.com" + } +} +``` ## 4. 启动角色与团队约定 diff --git a/docs/HEICODE_API_INTEGRATION.md b/docs/HEICODE_API_INTEGRATION.md index b0702b3..8d407ea 100644 --- a/docs/HEICODE_API_INTEGRATION.md +++ b/docs/HEICODE_API_INTEGRATION.md @@ -31,6 +31,7 @@ - ✅ 多 Agent 编排部署 - ✅ Heicode sub 模式敏捷开发对接(agile / waterfall) - ✅ `/api/swarms` Runtime 适配入口 +- ✅ 模板 Agent `/agents` 生命周期兼容接口 - ✅ 预算控制和计费管理 - ✅ 风险等级评估(low/medium/high) - ✅ Azure Key Vault `secret_ref` 引用 @@ -110,6 +111,7 @@ | usage / cost 回传 | 已支持 | `budget.alert` payload 带 `model_id`、token、成本、运行时长、资源秒、`billing_source` 和预算摘要 | | `/api/swarms/{id}/logs` 日志兜底 | 已支持 | 返回 Runtime 聚合日志摘要,不再只是固定占位文本 | | 空产物终态兜底 | 已支持 | 普通 sub terminal run 若未存储 concrete artifact,会生成 Runtime summary/failure artifact,并在 `/api/swarms/{id}`、`events`、`metrics` 中可见 | +| 模板 Agent `/agents` 生命周期兼容 | 已支持 | `POST /agents` 响应补充 `runtime_id` / `agent_id` / `id` / `runtime_status` / `state` / `subdomain`,并新增 `GET /agents/{id}`、`POST /agents/{id}/stop` | 仍属于后续增强或 Runtime 侧职责: @@ -133,6 +135,7 @@ | 查询时间线 | `GET /api/agent/user/deployments/{deployment_id}/timeline` | 已支持,由 callback event 合并 | | 查询 SK snapshot | `GET /api/agent/user/deployments/{deployment_id}/sk-snapshots` | 已支持投影查询,独立解析接口待增强 | | 审批 decision | `POST /api/swarms/{swarm_id}/approvals/{approval_id}` | 已支持 `approved` / `rejected` | +| 模板 Agent 生命周期 | `POST /agents`、`GET /agents/{id}`、`POST /agents/{id}/stop`、`DELETE /agents/{id}` | 已支持,适合 HM 模板 Agent 联调 | 当前实现边界: @@ -1213,6 +1216,104 @@ curl -L \ --- +### 3.11 模板 Agent Runtime 兼容接口 + +除 sub-mode runtime 外,当前仓库也保留了模板 Agent 的旧版统一入口 `POST /agents`。为对齐 HM 的模板 Agent 联调,本节补充这组接口的生命周期兼容契约。 + +#### 生命周期接口列表 + +| 方法 | 路径 | 说明 | +|------|------|------| +| `POST` | `/agents` | 创建模板 Agent;返回 HM 可直接解析的实例标识和访问地址别名字段 | +| `GET` | `/agents/{agent_name}` | 查询模板 Agent 生命周期状态;返回平铺 `status` / `runtime_status` / `state` | +| `POST` | `/agents/{agent_name}/stop` | 幂等停止模板 Agent;停止运行 Pod,但保留数据库记录 | +| `DELETE` | `/agents/{agent_name}` | 删除模板 Agent 运行资源和数据库记录 | +| `GET` | `/agents/{agent_name}/status` | 查询详细 Pod/容器状态与访问信息,适合排障 | +| `GET` | `/agents/{agent_name}/metrics` | 查询模板 Agent 资源使用信息 | + +#### `POST /agents` + +请求示例: + +```json +{ + "name": "dep-b5fab27e9255", + "template": "coding_a2a_agent", + "framework": "A2A", + "config": { + "user_id": "22", + "manager_deployment_id": "dep_b5fab27e9255", + "callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events" + }, + "env": { + "AGENT_ROLE_NAME": "architect", + "AGENT_INSTRUCTION_TEXT": "---\nname: architect\n---\n...", + "OPENAI_BASE_URL": "https://code.xinghanlab.com/v1", + "OPENAI_API_KEY": "sk-xxxx", + "MODEL_NAME": "gpt-5.4" + } +} +``` + +响应示例: + +```json +{ + "name": "dep-b5fab27e9255", + "runtime_id": "dep-b5fab27e9255", + "agent_id": "dep-b5fab27e9255", + "id": "dep-b5fab27e9255", + "namespace": "agent-dep-b5fab27e9255", + "status": "running", + "runtime_status": "running", + "state": "running", + "framework": "A2A", + "subdomain": "dep-b5fab27e9255.taijiagnet.com", + "access_token": null, + "access_info": { + "domain": "dep-b5fab27e9255.taijiagnet.com", + "domain_url": "http://dep-b5fab27e9255.taijiagnet.com", + "external_ip": "20.212.121.126" + } +} +``` + +字段兼容约定: + +- `runtime_id` / `agent_id` / `id` 当前都等于 Agent 名称,可直接作为后续生命周期调用的实例标识。 +- `subdomain` 取自 `access_info.domain`,若 DNS 尚未就绪则回退到 `access_info.external_ip`。 +- `runtime_status` / `state` 是对 Pod 生命周期的兼容投影;当前可能值为 `pending`、`running`、`stopped`、`failed`。 + +#### `GET /agents/{agent_name}` + +用于 HM 轮询模板 Agent 生命周期。返回体与 `POST /agents` 的核心生命周期字段保持一致,便于 HM 复用同一套解析逻辑。 + +#### `POST /agents/{agent_name}/stop` + +响应示例: + +```json +{ + "status": "success", + "message": "Agent dep-b5fab27e9255 已停止" +} +``` + +约定: + +- 该接口为幂等停止接口。 +- 停止动作会删除当前运行 Pod,并将数据库中的 Agent 状态收敛为 `stopped`。 +- 若要彻底清理实例,请在停止后继续调用 `DELETE /agents/{agent_name}`。 + +#### `DELETE /agents/{agent_name}` + +说明: + +- 删除接口当前已修复模板 Agent 场景下的数据库变量引用问题,不再出现此前的 `UnboundLocalError` 500。 +- 删除动作会清理 DNS、K8s namespace 以及数据库中的 Agent 记录。 + +--- + ## 4. 数据模型 ### 4.1 部署状态 (DeploymentStatus) @@ -1756,6 +1857,7 @@ except requests.exceptions.HTTPError as e: | 版本 | 日期 | 更新内容 | |------|------|----------| +| v2.1.11 | 2026-06-04 | 补充模板 Agent `/agents` 生命周期兼容文档:新增 `GET /agents/{id}`、`POST /agents/{id}/stop`、`DELETE /agents/{id}`、`GET /agents/{id}/status` 的联调说明;同步说明 `POST /agents` 额外返回 `runtime_id` / `agent_id` / `id` / `runtime_status` / `state` / `subdomain`,并记录删除接口 500 bug 已修复 | | v2.1.10 | 2026-05-30 | 文档补充生产环境产物获取路径:先查 artifact 列表,再用用户态 content 代理接口下载完整内容;明确 `azblob://` / `runtime://` 存储规则、Blob Secret 配置和排障提示 | | v2.1.9 | 2026-05-29 | Runtime artifact store 支持从 K8s Secret 读取 Azure Blob 凭据并上传完整产物,上传成功返回 `azblob://...` URI;内容读取接口支持 Runtime-local 与 AzBlob 两种来源 | | v2.1.8 | 2026-05-29 | 新增 Runtime-local artifact store:完整 agent 产物落盘保存,`artifact.created` 只回传摘要、URI、大小和 `content_hash`;新增 `/api/swarms/{id}/artifacts/{artifact_id}/content` 与用户态 artifact content 读取接口 | @@ -1771,6 +1873,6 @@ except requests.exceptions.HTTPError as e: --- -**文档版本**: v2.1.10 -**最后更新**: 2026-05-30 +**文档版本**: v2.1.11 +**最后更新**: 2026-06-04 **维护者**: Agent Manager Team diff --git a/tests/test_template_agent_contract.py b/tests/test_template_agent_contract.py new file mode 100644 index 0000000..9933cdc --- /dev/null +++ b/tests/test_template_agent_contract.py @@ -0,0 +1,68 @@ +"""Contract coverage for the template-agent lifecycle compatibility surface.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _read(relative_path: str) -> str: + """Load a repository file as text.""" + return (ROOT / relative_path).read_text(encoding="utf-8") + + +def test_template_agent_lifecycle_routes_are_registered(): + """The HM-facing template-agent lifecycle endpoints should exist.""" + app_source = _read("app.py") + + assert 'class AgentLifecycleResponse(BaseModel):' in app_source + assert '@app.get("/agents/{agent_name}", response_model=AgentLifecycleResponse)' in app_source + assert '@app.post("/agents/{agent_name}/stop", response_model=MessageResponse)' in app_source + assert '"runtime_id": resolved_name' in app_source + assert '"runtime_status": runtime_status' in app_source + assert '"subdomain": subdomain' in app_source + + +def test_create_response_exposes_hm_friendly_alias_fields(): + """POST /agents should emit the alias fields that HM already knows how to parse.""" + app_source = _read("app.py") + + assert "runtime_id: Optional[str] = None" in app_source + assert "agent_id: Optional[str] = None" in app_source + assert 'result["runtime_id"] = result["name"]' in app_source + assert 'result["agent_id"] = result["name"]' in app_source + assert 'result["runtime_status"] = _normalize_agent_runtime_status(result.get("status"))' in app_source + assert 'result["subdomain"] = access_info.get("domain") or access_info.get("external_ip")' in app_source + + +def test_delete_endpoint_queries_db_before_openclaw_branch(): + """DELETE /agents/{id} should not reference db_agent before it is loaded.""" + app_source = _read("app.py") + delete_start = app_source.index('@app.delete("/agents/{agent_name}", response_model=MessageResponse)') + delete_section = app_source[delete_start:] + + assert 'db_agent = db.query(Agent).filter(Agent.name == agent_name).first()' in delete_section + assert delete_section.index('db_agent = db.query(Agent).filter(Agent.name == agent_name).first()') < delete_section.index( + "if db_agent and db_agent.agent_framework:" + ) + + +def test_template_agent_doc_mentions_new_lifecycle_contract(): + """The coding A2A doc should describe the new lifecycle contract for HM.""" + doc_source = _read("docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md") + + assert "## 3.1 生命周期接口" in doc_source + assert "GET /agents/{agent_name}" in doc_source + assert "POST /agents/{agent_name}/stop" in doc_source + assert "`runtime_id` / `agent_id` / `id`" in doc_source + + +def test_integration_doc_mentions_template_agent_runtime_contract(): + """The main integration doc should include the /agents lifecycle compatibility APIs.""" + doc_source = _read("docs/HEICODE_API_INTEGRATION.md") + + assert "### 3.11 模板 Agent Runtime 兼容接口" in doc_source + assert "| `POST` | `/agents` | 创建模板 Agent;返回 HM 可直接解析的实例标识和访问地址别名字段 |" in doc_source + assert "| `GET` | `/agents/{agent_name}` | 查询模板 Agent 生命周期状态;返回平铺 `status` / `runtime_status` / `state` |" in doc_source + assert "| `POST` | `/agents/{agent_name}/stop` | 幂等停止模板 Agent;停止运行 Pod,但保留数据库记录 |" in doc_source + assert "删除接口当前已修复模板 Agent 场景下的数据库变量引用问题" in doc_source