Add template agent lifecycle compatibility APIs

This commit is contained in:
elipitc
2026-06-04 22:11:35 +08:00
parent 2e5321e16f
commit e0bf45db2f
4 changed files with 420 additions and 3 deletions
+206 -1
View File
@@ -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()
@@ -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. 启动角色与团队约定
+104 -2
View File
@@ -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<Agent_Prompt>...</Agent_Prompt>",
"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
+68
View File
@@ -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