Author SHA1 Message Date
elipitc 55780e652b Harden coding A2A workspace bootstrap 2026-06-05 17:53:11 +08:00
elipitc 3dd7da0b15 Document agent access token envs in templates 2026-06-05 00:18:21 +08:00
elipitc 8e9032e74a Add dedicated template agent contract doc 2026-06-04 23:40:14 +08:00
elipitc f6851c9680 Add A2A agent access token auth 2026-06-04 23:06:45 +08:00
elipitc e0bf45db2f Add template agent lifecycle compatibility APIs 2026-06-04 22:11:35 +08:00
elipitc 2e5321e16f Add coding A2A agent template and docs 2026-06-04 14:34:59 +08:00
elipitc 08ac5067be Wire secret-ref git delivery end to end 2026-06-03 16:52:12 +08:00
elipitc f4d7b9a5b1 Install git in runtime image 2026-06-03 14:05:37 +08:00
elipitc 253ea923cd Resolve git credentials from manager grants 2026-06-03 13:51:49 +08:00
elipitc 880139ab3e Refine runtime git delivery scaffolding 2026-06-03 12:44:07 +08:00
elipitc bf1508e49c Emit artifact revision conflict facts 2026-06-03 12:17:48 +08:00
elipitc 6de433d92b Add runtime revision and workflow detail fields 2026-06-03 11:12:36 +08:00
elipitc 504d9a1ab0 Expose richer sub-mode runtime workflow facts 2026-06-03 10:50:54 +08:00
elipitc aae209574a Add project-folder runtime artifacts 2026-06-03 09:56:59 +08:00
elipitc f306f2700b Sync sub-mode runtime semantics and k8s release 2026-06-02 00:09:40 +08:00
elipitc 1b49887819 Sync sub-mode runtime docs and k8s updates 2026-06-01 23:10:33 +08:00
elipitc 9bc172b22a Refresh sub-mode runtime integration guide 2026-06-01 20:39:13 +08:00
elipitc 487bff7e17 Fix runtime gateway configuration for sub-mode agents 2026-06-01 20:15:06 +08:00
elipitc 5e248a09bb Refine sub-mode runtime agent API surface 2026-06-01 19:36:09 +08:00
elipitc 532dca13f4 Update sub-mode runtime model handling 2026-05-31 22:33:49 +08:00
elipitc ac7d828e80 Merge branch 'feature/swarm-mode'
# Conflicts:
#	Dockerfile
#	k8s/agent-manager-configmap.yaml
#	k8s/agent-manager-deployment.yaml
2026-05-31 18:05:47 +08:00
elipitc 17937ecfd3 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	app.py
#	k8s/agent-manager-configmap.yaml
2026-05-31 18:04:07 +08:00
zhanggangyong 2e07f40cd4 Merge pull request 'feature/ad-creator-agent' (#2) from feature/ad-creator-agent into master
Reviewed-on: #2
2026-03-23 14:48:39 +00:00
zhanggangyong e3849bd538 Merge pull request 'fix: update Azure credentials (AZ_CLIENT_ID, AZ_CLIENT_SECRET, AZ_SUBSCRIPTION_ID)' (#1) from feature/rollback-1e07e85 into master
Reviewed-on: #1
2026-02-05 08:56:15 +00:00
zhanggangyong dd2fd11f75 new 2026-02-03 16:50:45 +00:00
zhanggangyong 0ddf2681ec dns解析问题解决 2026-02-03 12:25:40 +00:00
zhanggangyong 749e97cbe2 feat: support multiple ACR registries (openclawacr) 2026-02-02 16:42:05 +00:00
75 changed files with 9590 additions and 758 deletions
Vendored
BIN
View File
Binary file not shown.
+199
View File
@@ -0,0 +1,199 @@
# Agent Manager ARM64 构建和部署指南
本文档说明如何在 ARM64 架构的 AKS 集群上构建和部署 Agent Manager 项目。
## 前置要求
1. **Docker** (支持 buildx)
2. **kubectl** (已配置连接到 AKS 集群)
3. **Azure CLI** (已登录)
4. **Azure Container Registry (ACR)** 访问权限
## 快速开始
### 方法 1: 使用快速构建脚本(推荐)
```bash
# 一键构建并部署
./build-and-deploy-arm64.sh
```
### 方法 2: 使用完整部署脚本
```bash
# 构建镜像并部署
./scripts/deploy-to-k8s-arm64.sh
# 或跳过构建,仅部署
./scripts/deploy-to-k8s-arm64.sh --skip-build
```
## 详细步骤
### 1. 配置 Docker Buildx
确保 Docker Buildx 已启用并配置:
```bash
# 检查 buildx
docker buildx version
# 创建 ARM64 builder(如果不存在)
docker buildx create --name arm64-builder --use --driver docker-container
docker buildx inspect --bootstrap
```
### 2. 登录 Azure Container Registry
```bash
ACR_NAME="agnettaiji"
az acr login --name ${ACR_NAME}
```
### 3. 构建 ARM64 镜像
```bash
ACR_NAME="agnettaiji"
IMAGE_NAME="agent-manager"
IMAGE_TAG="latest-arm64"
FULL_IMAGE_NAME="${ACR_NAME}.azurecr.io/${IMAGE_NAME}:${IMAGE_TAG}"
docker buildx build \
--platform linux/arm64 \
-f Dockerfile \
-t ${FULL_IMAGE_NAME} \
--push \
.
```
### 4. 部署到 Kubernetes
确保 AKS 集群中有 ARM64 节点:
```bash
# 检查节点架构
kubectl get nodes -o wide
# 查看节点标签
kubectl get nodes --show-labels | grep arch
```
部署应用:
```bash
# 创建命名空间(如果不存在)
kubectl apply -f k8s/agent-manager-namespace.yaml
# 创建 ACR Secret(用于拉取镜像)
ACR_NAME="agnettaiji"
ACR_USERNAME=$(az acr credential show --name ${ACR_NAME} --query username -o tsv)
ACR_PASSWORD=$(az acr credential show --name ${ACR_NAME} --query passwords[0].value -o tsv)
kubectl create secret docker-registry acr-secret \
--namespace=agent-manager \
--docker-server=${ACR_NAME}.azurecr.io \
--docker-username=${ACR_USERNAME} \
--docker-password=${ACR_PASSWORD} \
--dry-run=client -o yaml | kubectl apply -f -
# 部署应用
kubectl apply -f k8s/agent-manager-deployment.yaml
kubectl apply -f k8s/agent-manager-service.yaml
```
### 5. 验证部署
```bash
# 查看 Pod 状态
kubectl get pods -n agent-manager -o wide
# 查看 Pod 详细信息(确认调度到 ARM64 节点)
kubectl describe pod -n agent-manager -l app=agent-manager
# 查看日志
kubectl logs -n agent-manager -l app=agent-manager -f
# 查看服务
kubectl get svc -n agent-manager
```
## 配置说明
### 镜像配置
- **镜像仓库**: `agnettaiji.azurecr.io`
- **镜像名称**: `agent-manager`
- **ARM64 标签**: `latest-arm64`
### 节点选择器
部署配置中已设置节点选择器,确保 Pod 调度到 ARM64 节点:
```yaml
nodeSelector:
kubernetes.io/arch: arm64
```
### 资源限制
默认资源配置:
- **请求**: CPU 200m, 内存 256Mi
- **限制**: CPU 500m, 内存 512Mi
可根据需要调整 `k8s/agent-manager-deployment.yaml` 中的资源配置。
## 故障排查
### 问题 1: 镜像拉取失败
**症状**: Pod 状态为 `ImagePullBackOff`
**解决**:
1. 检查 ACR Secret 是否正确创建
2. 确认 ACR 已附加到 AKS: `az aks update --name <aks-name> --resource-group <rg> --attach-acr <acr-name>`
3. 检查镜像标签是否正确
### 问题 2: Pod 无法调度
**症状**: Pod 状态为 `Pending`
**解决**:
1. 检查集群中是否有 ARM64 节点: `kubectl get nodes -l kubernetes.io/arch=arm64`
2. 检查节点选择器配置是否正确
3. 如果节点有污点,需要配置相应的容忍度
### 问题 3: 构建失败
**症状**: `docker buildx build` 失败
**解决**:
1. 确保 Docker Buildx 已正确安装和配置
2. 检查网络连接(推送镜像需要)
3. 确认 ACR 登录状态: `az acr login --name <acr-name>`
## 更新部署
更新镜像后,需要重启 Pod 以使用新镜像:
```bash
# 方法 1: 删除 Pod(Deployment 会自动创建新的)
kubectl delete pod -n agent-manager -l app=agent-manager
# 方法 2: 滚动更新
kubectl rollout restart deployment/agent-manager -n agent-manager
# 方法 3: 更新镜像标签
kubectl set image deployment/agent-manager \
agent-manager=agnettaiji.azurecr.io/agent-manager:latest-arm64 \
-n agent-manager
```
## 相关文件
- `Dockerfile` - Docker 镜像构建文件
- `k8s/agent-manager-deployment.yaml` - Kubernetes 部署配置
- `k8s/agent-manager-service.yaml` - Kubernetes 服务配置
- `scripts/deploy-to-k8s-arm64.sh` - 完整部署脚本
- `build-and-deploy-arm64.sh` - 快速构建和部署脚本
+9
View File
@@ -1,7 +1,16 @@
# 支持多架构构建(包括 ARM64)
# 使用 buildx 构建: docker buildx build --platform linux/arm64 -t <image> .
FROM python:3.11-slim
WORKDIR /app
# 安装系统依赖(包括 openssl 用于生成自签名证书)
RUN apt-get update && apt-get install -y \
curl \
git \
openssl \
&& rm -rf /var/lib/apt/lists/*
# 复制应用代码
COPY requirements.txt .
COPY app.py .
+206
View File
@@ -0,0 +1,206 @@
# OPENCLAW AKS 部署 HTTPS 配置指南
## 概述
本指南说明如何为 OPENCLAW 平台 agent 在 AKS 上配置 HTTPS 访问,使用自签名证书解决只有 DNS 域名但没有正式证书的问题。
## 前置条件
1. 已部署 OPENCLAW 到 AKS
2. 已安装 nginx-ingress-controller
3. 有域名指向 AKS Ingress IP
4. 已安装 `kubectl` 和 `openssl`
## 解决方案:使用自签名证书
### 步骤 1: 生成自签名证书
使用提供的脚本生成自签名证书:
```bash
# 给脚本添加执行权限
chmod +x generate-self-signed-cert.sh
# 运行脚本生成证书(替换为你的实际域名)
./generate-self-signed-cert.sh openclaw.yourdomain.com openclaw openclaw-tls
```
**参数说明:**
- 第一个参数:你的域名(例如:`openclaw.example.com`)
- 第二个参数:Kubernetes 命名空间(默认:`openclaw`)
- 第三个参数:Kubernetes Secret 名称(默认:`openclaw-tls`)
### 步骤 2: 更新部署配置
确保 `deploay.yaml` 中的 Ingress 配置已包含 TLS 部分(已更新):
```yaml
spec:
tls:
- hosts:
- openclaw.yourdomain.com # 你的域名
secretName: openclaw-tls # Secret 名称
rules:
- host: openclaw.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: openclaw
port:
number: 18789
```
### 步骤 3: 应用配置
```bash
# 应用更新后的配置
kubectl apply -f deploay.yaml
# 验证 Ingress 配置
kubectl get ingress -n openclaw
# 查看证书 Secret
kubectl get secret openclaw-tls -n openclaw
```
### 步骤 4: 配置 DNS
确保你的域名指向 AKS Ingress 的外部 IP:
```bash
# 获取 Ingress IP
kubectl get ingress -n openclaw
# 在 DNS 提供商处添加 A 记录:
# openclaw.yourdomain.com -> <INGRESS_IP>
```
### 步骤 5: 访问测试
1. 在浏览器中访问:`https://openclaw.yourdomain.com`
2. 浏览器会显示安全警告(这是正常的,因为使用的是自签名证书)
3. 点击"高级" -> "继续访问"(Chrome)或"接受风险并继续"(Firefox)
4. 之后即可正常访问 OPENCLAW UI
## 手动生成证书(可选)
如果脚本无法使用,可以手动生成:
```bash
# 1. 生成私钥
openssl genrsa -out tls.key 2048
# 2. 生成证书签名请求
openssl req -new -key tls.key -out tls.csr \
-subj "/C=CN/ST=Beijing/L=Beijing/O=OpenClaw/CN=openclaw.yourdomain.com"
# 3. 生成自签名证书(包含 SAN)
openssl x509 -req -days 365 -in tls.csr -signkey tls.key \
-out tls.crt \
-extensions v3_req \
-extfile <(cat <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = openclaw.yourdomain.com
DNS.2 = *.openclaw.yourdomain.com
DNS.3 = localhost
IP.1 = 127.0.0.1
EOF
)
# 4. 创建 Kubernetes Secret
kubectl create secret tls openclaw-tls \
--cert=tls.crt \
--key=tls.key \
--namespace=openclaw
```
## 注意事项
### 自签名证书的限制
1. **浏览器警告**:所有浏览器都会显示安全警告,需要用户手动接受
2. **有效期**:默认证书有效期为 365 天,到期后需要重新生成
3. **不适用于生产环境**:自签名证书不适合生产环境,仅用于开发/测试
### 生产环境建议
对于生产环境,建议使用:
1. **Let's Encrypt**(免费,自动续期)
```bash
# 安装 cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
# 配置 ClusterIssuer
# 然后 Ingress 添加注解:
# cert-manager.io/cluster-issuer: letsencrypt-prod
```
2. **Azure Key Vault**(Azure 托管证书)
3. **购买商业证书**
### 更新证书
证书到期后,重新生成并更新:
```bash
# 重新生成证书
./generate-self-signed-cert.sh openclaw.yourdomain.com openclaw openclaw-tls
# 重启 Ingress Controller(如果需要)
kubectl rollout restart deployment -n ingress-nginx ingress-nginx-controller
```
## 故障排查
### 问题 1: 证书 Secret 不存在
```bash
# 检查 Secret
kubectl get secret openclaw-tls -n openclaw
# 如果不存在,重新创建
./generate-self-signed-cert.sh <your-domain> openclaw openclaw-tls
```
### 问题 2: Ingress 无法访问
```bash
# 检查 Ingress 状态
kubectl describe ingress openclaw -n openclaw
# 检查 Ingress Controller
kubectl get pods -n ingress-nginx
# 检查 Service
kubectl get svc openclaw -n openclaw
```
### 问题 3: HTTPS 连接失败
```bash
# 检查证书是否正确加载
kubectl get ingress openclaw -n openclaw -o yaml | grep -A 5 tls
# 检查 Ingress Controller 日志
kubectl logs -n ingress-nginx -l app.kubernetes.io/component=controller
```
## 总结
使用自签名证书可以快速解决 OPENCLAW 在 AKS 上需要 HTTPS 访问的问题。虽然会有浏览器警告,但对于开发和测试环境已经足够。生产环境建议使用 Let's Encrypt 或商业证书。
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -8,6 +8,7 @@ import asyncio
import json
import uuid
import os
import secrets
from typing import Optional, Dict, Any, AsyncGenerator
from datetime import datetime
from contextlib import asynccontextmanager
@@ -18,7 +19,7 @@ from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
import structlog
from agent import LiteLLMAgent
from agent import LiteLLMAgent, ModelRequestError
from config import get_config, AgentConfig, A2AConfig
try:
@@ -38,6 +39,9 @@ SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
POD_NAME = os.getenv("POD_NAME", "a2a-litellm-agent")
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "a2a_litellm_agent")
USER_ID = os.getenv("USER_ID", "")
HEICODE_AGENT_ID = os.getenv("HEICODE_AGENT_ID", "")
AGENT_ACCESS_TOKEN = os.getenv("AGENT_ACCESS_TOKEN", "")
AGENT_ACCESS_HEADER = "X-Agent-Access-Token"
# ============== A2A 协议数据模型 ==============
@@ -94,6 +98,7 @@ class A2ATask(BaseModel):
contextId: str = Field(default_factory=lambda: uuid.uuid4().hex)
status: A2ATaskStatus
artifacts: Optional[list[A2AArtifact]] = None
metadata: Optional[Dict[str, Any]] = None
class A2AResponse(BaseModel):
@@ -180,6 +185,39 @@ class A2AAgentServer:
# 创建FastAPI应用
self.app = self._create_app()
def _agent_access_required(self) -> bool:
return bool(AGENT_ACCESS_TOKEN)
def _agent_authentication_card(self) -> Optional[Dict[str, Any]]:
if not self._agent_access_required():
return None
return {
"type": "header",
"header": AGENT_ACCESS_HEADER,
"required": True,
"agent_id": HEICODE_AGENT_ID or POD_NAME,
}
def _authorize_agent_request(self, request: Request) -> Optional[JSONResponse]:
expected_token = AGENT_ACCESS_TOKEN
if not expected_token:
return None
provided_token = request.headers.get(AGENT_ACCESS_HEADER, "")
if not provided_token:
return JSONResponse(
status_code=401,
content={"detail": f"missing {AGENT_ACCESS_HEADER}"},
)
if not secrets.compare_digest(expected_token, provided_token):
return JSONResponse(
status_code=403,
content={"detail": "agent access denied"},
)
return None
def _create_app(self) -> FastAPI:
"""创建FastAPI应用"""
@@ -237,7 +275,9 @@ class A2AAgentServer:
"protocol": "A2A",
"status": "running",
"pod_name": POD_NAME,
"template_type": TEMPLATE_TYPE
"template_type": TEMPLATE_TYPE,
"auth_required": self._agent_access_required(),
"agent_id": HEICODE_AGENT_ID or POD_NAME,
}
@app.get("/health")
@@ -248,6 +288,8 @@ class A2AAgentServer:
"pod_name": POD_NAME,
"template_type": TEMPLATE_TYPE,
"configured": self.llm_config.api_key is not None,
"auth_required": self._agent_access_required(),
"agent_id": HEICODE_AGENT_ID or POD_NAME,
"timestamp": datetime.utcnow().isoformat()
}
@@ -266,6 +308,7 @@ class A2AAgentServer:
streaming=self.agent_config.enable_streaming,
push_notifications=False
),
authentication=self._agent_authentication_card(),
skills=[
AgentSkill(
id="general-assistant",
@@ -284,6 +327,9 @@ class A2AAgentServer:
@app.post("/message/send")
async def send_message(request: Request):
"""A2A message/send 端点"""
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
body = await request.json()
# 解析JSON-RPC请求
@@ -317,6 +363,9 @@ class A2AAgentServer:
@app.post("/message/stream")
async def stream_message(request: Request):
"""A2A message/stream 端点 (SSE流式响应)"""
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
body = await request.json()
try:
@@ -334,8 +383,11 @@ class A2AAgentServer:
return await self._handle_message_stream(rpc_request)
@app.get("/tasks/{task_id}")
async def get_task(task_id: str):
async def get_task(task_id: str, request: Request):
"""获取任务状态"""
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
if task_id not in self.tasks:
raise HTTPException(status_code=404, detail="Task not found")
return self.tasks[task_id].model_dump()
@@ -392,12 +444,12 @@ class A2AAgentServer:
request_id=task_id
) as ctx:
ctx.add_tool("a2a_chat")
response_text = await agent.chat(
response = await agent.chat_result(
message=user_text,
conversation_id=context_id
)
else:
response_text = await agent.chat(
response = await agent.chat_result(
message=user_text,
conversation_id=context_id
)
@@ -411,9 +463,17 @@ class A2AAgentServer:
task.artifacts = [
A2AArtifact(
name="response",
parts=[A2APart(kind="text", text=response_text)]
parts=[A2APart(kind="text", text=response.get("content", ""))]
)
]
task.metadata = {
"newapi_request_id": response.get("request_id"),
"response_id": response.get("response_id"),
"model": response.get("model"),
"api_format": response.get("api_format"),
"endpoint": response.get("endpoint"),
"usage": response.get("usage") or {},
}
self.tasks[task_id] = task
return JSONResponse({
@@ -425,6 +485,9 @@ class A2AAgentServer:
except Exception as e:
logger.error("处理消息失败", error=str(e))
task.status = A2ATaskStatus(state="failed", message=str(e))
error_data = {}
if isinstance(e, ModelRequestError):
error_data = e.to_dict()
self.tasks[task_id] = task
return JSONResponse({
@@ -432,7 +495,8 @@ class A2AAgentServer:
"id": request.id,
"error": {
"code": -32000,
"message": f"Agent error: {str(e)}"
"message": f"Agent error: {str(e)}",
"data": error_data,
}
})
+347 -11
View File
@@ -6,7 +6,7 @@ LiteLLM Agent 核心模块
import asyncio
import json
import uuid
from typing import AsyncGenerator, Optional, Dict, Any, List
from typing import AsyncGenerator, Optional, Dict, Any, List, Union
from dataclasses import dataclass, field
from datetime import datetime
@@ -47,6 +47,54 @@ class Conversation:
return [{"role": m.role, "content": m.content} for m in self.messages]
@dataclass
class ModelResult:
"""Normalized model response metadata for Runtime accounting."""
content: str
usage: Dict[str, int] = field(default_factory=dict)
request_id: Optional[str] = None
response_id: Optional[str] = None
model: Optional[str] = None
api_format: str = "openai_chat"
endpoint: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
return {
"content": self.content,
"usage": self.usage,
"request_id": self.request_id,
"response_id": self.response_id,
"model": self.model,
"api_format": self.api_format,
"endpoint": self.endpoint,
}
class ModelRequestError(RuntimeError):
"""Model gateway error carrying request metadata for Runtime logs."""
def __init__(
self,
message: str,
*,
request_id: Optional[str] = None,
status_code: Optional[int] = None,
response_text: Optional[str] = None,
):
super().__init__(message)
self.request_id = request_id
self.status_code = status_code
self.response_text = response_text
def to_dict(self) -> Dict[str, Any]:
return {
"request_id": self.request_id,
"status_code": self.status_code,
"response_text": self.response_text,
}
class LiteLLMAgent:
"""
基于LiteLLM的Agent实现
@@ -110,6 +158,8 @@ class LiteLLMAgent:
timeout=httpx.Timeout(self.llm_config.timeout),
headers={
"Authorization": f"Bearer {self.llm_config.api_key}",
"x-api-key": self.llm_config.api_key,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json"
}
)
@@ -144,7 +194,7 @@ class LiteLLMAgent:
message: str,
conversation_id: Optional[str] = None,
stream: bool = False
) -> str | AsyncGenerator[str, None]:
) -> Union[str, AsyncGenerator[str, None]]:
"""
发送消息并获取回复
@@ -162,12 +212,73 @@ class LiteLLMAgent:
conversation.add_message("user", message)
if stream:
if self.llm_config.api_format == "anthropic_messages":
return self._stream_anthropic_messages_text(conversation)
return self._stream_chat(conversation)
else:
return await self._simple_chat(conversation)
result = await self.chat_result_for_conversation(conversation)
return result.content
async def chat_result(
self,
message: str,
conversation_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Return assistant text plus usage and NewAPI request metadata."""
conversation = self.get_or_create_conversation(conversation_id)
conversation.add_message("user", message)
return (await self.chat_result_for_conversation(conversation)).to_dict()
async def chat_result_for_conversation(self, conversation: Conversation) -> ModelResult:
"""Dispatch to the configured model API format."""
if self.llm_config.api_format == "anthropic_messages":
if self.llm_config.use_stream:
return await self._anthropic_messages_stream(conversation)
return await self._anthropic_messages(conversation)
if self.llm_config.use_stream:
return await self._openai_chat_stream_result(conversation)
return await self._simple_chat(conversation)
async def _simple_chat(self, conversation: Conversation) -> str:
"""非流式对话"""
def _request_id_from_response(self, response: httpx.Response, body: Optional[Dict[str, Any]] = None) -> Optional[str]:
"""Extract NewAPI/OpenAI/Anthropic request ID from headers or body."""
for name in (
"x-request-id",
"request-id",
"x-newapi-request-id",
"x-litellm-request-id",
"anthropic-request-id",
):
value = response.headers.get(name)
if value:
return value
if body:
return body.get("request_id")
return None
def _normalize_usage(self, usage: Optional[Dict[str, Any]]) -> Dict[str, int]:
usage = usage or {}
prompt_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
if "input_tokens" in usage or "output_tokens" in usage:
total_tokens = prompt_tokens + completion_tokens
else:
total_tokens = int(usage.get("total_tokens") or prompt_tokens + completion_tokens)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
}
def _raise_gateway_error(self, exc: httpx.HTTPStatusError, body: Optional[Dict[str, Any]] = None) -> None:
request_id = self._request_id_from_response(exc.response, body)
raise ModelRequestError(
f"Server error '{exc.response.status_code} {exc.response.reason_phrase}' for url '{exc.request.url}'",
request_id=request_id,
status_code=exc.response.status_code,
response_text=exc.response.text[:2000],
) from exc
async def _simple_chat(self, conversation: Conversation) -> ModelResult:
"""非流式 OpenAI chat completions 对话"""
client = await self._get_client()
request_body = {
@@ -184,7 +295,10 @@ class LiteLLMAgent:
self.llm_config.chat_endpoint,
json=request_body
)
response.raise_for_status()
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
self._raise_gateway_error(exc)
result = response.json()
assistant_message = result["choices"][0]["message"]["content"]
@@ -192,8 +306,17 @@ class LiteLLMAgent:
# 保存助手回复到对话
conversation.add_message("assistant", assistant_message)
logger.info("收到回复", length=len(assistant_message))
return assistant_message
request_id = self._request_id_from_response(response, result)
logger.info("收到回复", length=len(assistant_message), request_id=request_id)
return ModelResult(
content=assistant_message,
usage=self._normalize_usage(result.get("usage")),
request_id=request_id,
response_id=result.get("id"),
model=result.get("model") or self.llm_config.model,
api_format="openai_chat",
endpoint=self.llm_config.chat_endpoint,
)
except httpx.HTTPStatusError as e:
logger.error("HTTP错误", status_code=e.response.status_code, detail=e.response.text)
@@ -201,6 +324,215 @@ class LiteLLMAgent:
except Exception as e:
logger.error("请求失败", error=str(e))
raise
async def _openai_chat_stream_result(self, conversation: Conversation) -> ModelResult:
"""OpenAI chat completions stream=true, aggregated into one Runtime artifact."""
client = await self._get_client()
request_body = {
"model": self.llm_config.model,
"messages": conversation.to_openai_format(),
"temperature": self.llm_config.temperature,
"max_tokens": self.llm_config.max_tokens,
"stream": True,
"stream_options": {"include_usage": True},
}
full_response = ""
usage: Dict[str, int] = {}
response_id: Optional[str] = None
request_id: Optional[str] = None
try:
async with client.stream("POST", self.llm_config.chat_endpoint, json=request_body) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
self._raise_gateway_error(exc)
request_id = self._request_id_from_response(response)
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
chunk = json.loads(data)
except json.JSONDecodeError:
continue
response_id = response_id or chunk.get("id")
request_id = request_id or chunk.get("request_id")
if chunk.get("usage"):
usage = self._normalize_usage(chunk.get("usage"))
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
full_response += content
conversation.add_message("assistant", full_response)
logger.info("收到流式回复", length=len(full_response), request_id=request_id)
return ModelResult(
content=full_response,
usage=usage,
request_id=request_id or response_id,
response_id=response_id,
model=self.llm_config.model,
api_format="openai_chat",
endpoint=self.llm_config.chat_endpoint,
)
except Exception as e:
logger.error("流式请求失败", error=str(e))
raise
def _anthropic_payload(self, conversation: Conversation, *, stream: bool = False) -> Dict[str, Any]:
system_parts: List[str] = []
messages: List[Dict[str, str]] = []
for message in conversation.messages:
if message.role == "system":
system_parts.append(message.content)
else:
role = "assistant" if message.role == "assistant" else "user"
messages.append({"role": role, "content": message.content})
payload: Dict[str, Any] = {
"model": self.llm_config.model,
"messages": messages,
"max_tokens": self.llm_config.max_tokens,
"stream": stream,
}
if system_parts:
payload["system"] = "\n\n".join(system_parts)
return payload
def _anthropic_text(self, body: Dict[str, Any]) -> str:
content = body.get("content") or []
texts = [
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
]
return "".join(texts)
async def _anthropic_messages(self, conversation: Conversation) -> ModelResult:
"""Anthropic Messages-compatible call for Claude models."""
client = await self._get_client()
try:
response = await client.post(self.llm_config.messages_endpoint, json=self._anthropic_payload(conversation))
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
self._raise_gateway_error(exc)
result = response.json()
assistant_message = self._anthropic_text(result)
conversation.add_message("assistant", assistant_message)
request_id = self._request_id_from_response(response, result)
logger.info("收到 Claude Messages 回复", length=len(assistant_message), request_id=request_id)
return ModelResult(
content=assistant_message,
usage=self._normalize_usage(result.get("usage")),
request_id=request_id,
response_id=result.get("id"),
model=result.get("model") or self.llm_config.model,
api_format="anthropic_messages",
endpoint=self.llm_config.messages_endpoint,
)
except Exception as e:
logger.error("Claude Messages 请求失败", error=str(e))
raise
async def _anthropic_messages_stream(self, conversation: Conversation) -> ModelResult:
"""Anthropic Messages stream=true, aggregated into one Runtime artifact."""
client = await self._get_client()
full_response = ""
usage: Dict[str, int] = {}
response_id: Optional[str] = None
request_id: Optional[str] = None
try:
async with client.stream(
"POST",
self.llm_config.messages_endpoint,
json=self._anthropic_payload(conversation, stream=True),
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
self._raise_gateway_error(exc)
request_id = self._request_id_from_response(response)
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
event = json.loads(data)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "message_start":
message = event.get("message") or {}
response_id = response_id or message.get("id")
usage = self._normalize_usage(message.get("usage"))
elif event_type == "content_block_delta":
delta = event.get("delta") or {}
text = delta.get("text", "")
if text:
full_response += text
elif event_type == "message_delta":
delta_usage = (event.get("usage") or {})
if delta_usage:
usage = self._normalize_usage({**usage, **delta_usage})
conversation.add_message("assistant", full_response)
logger.info("收到 Claude Messages 流式回复", length=len(full_response), request_id=request_id)
return ModelResult(
content=full_response,
usage=usage,
request_id=request_id or response_id,
response_id=response_id,
model=self.llm_config.model,
api_format="anthropic_messages",
endpoint=self.llm_config.messages_endpoint,
)
except Exception as e:
logger.error("Claude Messages 流式请求失败", error=str(e))
raise
async def _stream_anthropic_messages_text(self, conversation: Conversation) -> AsyncGenerator[str, None]:
"""Yield text deltas from Anthropic Messages stream for A2A stream clients."""
client = await self._get_client()
full_response = ""
try:
async with client.stream(
"POST",
self.llm_config.messages_endpoint,
json=self._anthropic_payload(conversation, stream=True),
) as response:
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
self._raise_gateway_error(exc)
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
try:
event = json.loads(data)
except json.JSONDecodeError:
continue
if event.get("type") != "content_block_delta":
continue
delta = event.get("delta") or {}
text = delta.get("text", "")
if text:
full_response += text
yield text
conversation.add_message("assistant", full_response)
except Exception as e:
logger.error("Claude Messages 文本流失败", error=str(e))
raise
async def _stream_chat(self, conversation: Conversation) -> AsyncGenerator[str, None]:
"""流式对话"""
@@ -211,7 +543,8 @@ class LiteLLMAgent:
"messages": conversation.to_openai_format(),
"temperature": self.llm_config.temperature,
"max_tokens": self.llm_config.max_tokens,
"stream": True
"stream": True,
"stream_options": {"include_usage": True},
}
full_response = ""
@@ -232,7 +565,10 @@ class LiteLLMAgent:
try:
chunk = json.loads(data)
delta = chunk.get("choices", [{}])[0].get("delta", {})
choices = chunk.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content", "")
if content:
full_response += content
@@ -18,8 +18,9 @@ class LiteLLMConfig:
# 基础URL - 用户提供的LiteLLM服务地址
base_url: str = "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io"
# 完整的chat completions端点
# 完整的模型端点
chat_endpoint: str = field(init=False)
messages_endpoint: str = field(init=False)
# API密钥 - 优先使用传入的,否则从环境变量获取
api_key: Optional[str] = None
@@ -38,6 +39,12 @@ class LiteLLMConfig:
# 最大token数
max_tokens: int = 4096
# API格式:openai_chat 或 anthropic_messages
api_format: str = "openai_chat"
# 是否强制使用流式请求聚合完整响应
use_stream: bool = False
def __post_init__(self):
self.base_url = (
@@ -52,7 +59,35 @@ class LiteLLMConfig:
self.api_key = os.getenv("LITELLM_API_KEY")
if self.model is None:
self.model = os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL", "gpt-4")
model_name = (self.model or "").lower()
self.api_format = (
os.getenv("LLM_API_FORMAT")
or os.getenv("MODEL_API_FORMAT")
or ("anthropic_messages" if "claude" in model_name else "openai_chat")
).lower()
if "gpt-5.4" in model_name:
self.timeout = 600
self.use_stream = True
if "claude" in model_name:
self.timeout = 600
self.use_stream = True
if os.getenv("LITELLM_TIMEOUT") or os.getenv("LLM_TIMEOUT"):
self.timeout = int(os.getenv("LITELLM_TIMEOUT") or os.getenv("LLM_TIMEOUT"))
if os.getenv("LITELLM_MAX_TOKENS") or os.getenv("LLM_MAX_TOKENS"):
self.max_tokens = int(os.getenv("LITELLM_MAX_TOKENS") or os.getenv("LLM_MAX_TOKENS"))
if os.getenv("LITELLM_STREAM") or os.getenv("LLM_STREAM"):
self.use_stream = (os.getenv("LITELLM_STREAM") or os.getenv("LLM_STREAM", "")).lower() in {
"1",
"true",
"yes",
"on",
}
self.chat_endpoint = f"{self.base_url}/chat/completions"
self.messages_endpoint = f"{self.base_url}/messages"
def validate(self) -> bool:
"""验证配置是否完整"""
@@ -0,0 +1,186 @@
# Coding A2A Agent
一个类似 Claude Code 的编程 Agent 模板:
- 核心调度使用 `Pydantic AI`
- 对外暴露 `A2A` 协议
- 提供代码工作区工具:`read_file`、`list_files`、`write_file`、`edit_file`、`run_command`
- 提供 Git 资源工具:兼容 `Gitea`、`GitHub`、`GitLab`
- 提供资源工具:`MySQL`、`PostgreSQL`、`Azure Blob`
## 适用场景
- 让 Agent 在工作区内像 Claude Code 一样理解和修改代码
- 让上层系统通过 A2A 协议发起编程任务
- 在同一个 Agent 中挂接 Git、数据库和 Blob 资源,帮助代码开发和排查
## 环境变量
| 变量 | 说明 |
| --- | --- |
| `OPENAI_BASE_URL` / `LITELLM_BASE_URL` | LiteLLM / OpenAI 兼容网关地址 |
| `OPENAI_API_KEY` / `LITELLM_API_KEY` | 模型 API Key |
| `MODEL_NAME` / `LITELLM_MODEL` | 模型名称 |
| `WORK_DIR` | 默认工作区目录,默认 `/workspace` |
| `AGENT_ROLE_NAME` | 启动时指定角色名,例如 `backend`、`reviewer` |
| `AGENT_INSTRUCTION_TEXT` | 启动时直接注入角色/行为说明文本,支持类似 `AGENTS.md` / `claude.md` 内容 |
| `AGENT_INSTRUCTION_FILE` | 启动时读取角色说明文件路径,文件内容会并入系统提示词 |
| `AGENT_ACCESS_TOKEN` | 可选。若设置,则 A2A 请求必须携带 `X-Agent-Access-Token` 且与其完全匹配 |
| `HEICODE_AGENT_ID` | 可选。用于在健康检查和 agent card 中暴露上层分配的 agent 标识 |
| `SERVICE_PORT` | 服务端口,默认 `8000` |
## 动态资源工具
这些资源工具都可以在启动时通过环境变量动态挂上。是否真的调用这些工具,由 agent 自己根据任务判断。
如果完全不传,对应工具依然存在,但调用时会返回 `resource not configured`,不会阻止 agent 启动。
### Git
可选环境变量:
- `GIT_REPO_URL`
- `GIT_PROVIDER`
- `GIT_USERNAME`
- `GIT_PASSWORD`
- `GIT_TOKEN`
- `GIT_DEFAULT_BRANCH`
- `GIT_LOCAL_PATH`
- `GIT_ALLOWED_PATHS`:逗号分隔
- `GIT_WRITE_MODE`
### MySQL
至少需要:
- `MYSQL_HOST`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_DATABASE`
可选:
- `MYSQL_PORT`
- `MYSQL_SSL_MODE`
### PostgreSQL
至少需要:
- `POSTGRES_HOST`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_DATABASE`
可选:
- `POSTGRES_PORT`
- `POSTGRES_SSL_MODE`
也兼容 `POSTGRESQL_*` 变量名。
### Azure Blob
至少需要:
- `AZURE_BLOB_CONTAINER`
再配下面任意一套:
1. `AZURE_BLOB_CONNECTION_STRING`
2. `AZURE_BLOB_ACCOUNT_URL` + `AZURE_BLOB_SAS_TOKEN`
3. `AZURE_BLOB_ACCOUNT_URL` + `AZURE_BLOB_ACCOUNT_KEY`
4. `AZURE_BLOB_ACCOUNT_NAME` + `AZURE_BLOB_ACCOUNT_KEY`
可选:
- `AZURE_BLOB_PREFIX`
也兼容:
- `AZURE_STORAGE_CONNECTION_STRING`
- `AZURE_STORAGE_CONTAINER`
- `AZURE_STORAGE_ACCOUNT_NAME`
- `AZURE_STORAGE_ACCOUNT_KEY`
- `AZURE_STORAGE_PREFIX`
## 启动角色注入
如果你想让这个模板在启动时就带上固定角色或团队约定,可以直接通过环境变量传入。
示例 1:直接传文本
```bash
export AGENT_ROLE_NAME=backend
export AGENT_INSTRUCTION_TEXT=$'# Role\n你是 backend engineer\n\n# Constraints\n- 先读 README 和 api 目录\n- 修改后必须运行测试\n- 不改 frontend 目录'
```
示例 2:传文件路径
```bash
export AGENT_ROLE_NAME=reviewer
export AGENT_INSTRUCTION_FILE=/workspace/AGENTS.md
```
优先级:
1. `AGENT_INSTRUCTION_TEXT`
2. `AGENT_INSTRUCTION_FILE`
3. 默认通用系统提示词
如果两者都没有,模板会退回通用 coding agent 行为。
## A2A 示例
如果设置了 `AGENT_ACCESS_TOKEN`,调用 `/message/send`、`/message/stream`、`/tasks/{task_id}` 时需要带:
```http
X-Agent-Access-Token: <AGENT_ACCESS_TOKEN>
```
服务端会使用常量时间比较校验请求头与环境变量值;未设置 `AGENT_ACCESS_TOKEN` 的旧实例继续兼容放行。
`POST /message/send`
```json
{
"jsonrpc": "2.0",
"id": "demo-1",
"method": "message/send",
"params": {
"message": {
"role": "user",
"parts": [
{
"kind": "text",
"text": "先阅读 README.md 和 app/main.py,然后把健康检查接口补成返回 version 字段。"
}
]
},
"configuration": {
"workspace": {
"root_dir": "/workspace/repo",
"entry_file": "app/main.py",
"context_files": ["README.md"],
"allowed_paths": ["app", "tests", "README.md"]
},
"resources": {
"git": {
"repo_url": "https://gitee.example.com/acme/demo.git",
"provider": "gitea",
"default_branch": "main"
}
}
}
}
}
```
## 返回内容
- A2A `task`
- 编程结果文本
- `summary`
- `files_changed`
- `tool_log`
- `resources_used`
@@ -0,0 +1,38 @@
"""
Coding A2A Agent package.
"""
from .config import (
AgentMetadata,
AzureBlobResourceConfig,
CodingRequestConfig,
DatabaseResourceConfig,
GitResourceConfig,
LiteLLMConfig,
ResourceConfig,
WorkspaceConfig,
get_runtime_defaults,
)
try:
from .agent import CodingA2ARuntime
from .a2a_server import CodingA2AServer, create_app
except Exception: # pragma: no cover - optional during lightweight config tests
CodingA2ARuntime = None
CodingA2AServer = None
create_app = None
__all__ = [
"AgentMetadata",
"AzureBlobResourceConfig",
"CodingA2ARuntime",
"CodingA2AServer",
"CodingRequestConfig",
"DatabaseResourceConfig",
"GitResourceConfig",
"LiteLLMConfig",
"ResourceConfig",
"WorkspaceConfig",
"create_app",
"get_runtime_defaults",
]
@@ -0,0 +1,445 @@
"""
A2A server for the coding agent.
"""
from __future__ import annotations
import json
import os
import secrets
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Any, AsyncGenerator, Optional
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, ValidationError
from coding_a2a_agent.common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
from coding_a2a_agent.agent import CodingA2ARuntime, CodingRuntimeError
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, LiteLLMConfig
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
POD_NAME = os.getenv("POD_NAME", "coding-a2a-agent")
TEMPLATE_TYPE = os.getenv("TEMPLATE_TYPE", "coding_a2a_agent")
USER_ID = os.getenv("USER_ID", "")
HEICODE_AGENT_ID = os.getenv("HEICODE_AGENT_ID", "")
AGENT_ACCESS_TOKEN = os.getenv("AGENT_ACCESS_TOKEN", "")
AGENT_ACCESS_HEADER = "X-Agent-Access-Token"
class A2APart(BaseModel):
kind: str = "text"
text: Optional[str] = None
data: Optional[dict[str, Any]] = None
mime_type: Optional[str] = None
class A2AMessage(BaseModel):
role: str
parts: list[A2APart]
messageId: str = Field(default_factory=lambda: uuid.uuid4().hex)
class A2ARequest(BaseModel):
jsonrpc: str = "2.0"
id: str
method: str
params: Optional[dict[str, Any]] = None
class A2AArtifact(BaseModel):
artifactId: str = Field(default_factory=lambda: uuid.uuid4().hex)
name: str = "coding-result"
parts: list[A2APart]
class A2ATaskStatus(BaseModel):
state: str
timestamp: str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")
message: Optional[str] = None
class A2ATask(BaseModel):
kind: str = "task"
id: str = Field(default_factory=lambda: uuid.uuid4().hex)
contextId: str = Field(default_factory=lambda: uuid.uuid4().hex)
status: A2ATaskStatus
artifacts: Optional[list[A2AArtifact]] = None
metadata: Optional[dict[str, Any]] = None
class AgentSkill(BaseModel):
id: str
name: str
description: str
class AgentCapabilities(BaseModel):
text: bool = True
streaming: bool = True
push_notifications: bool = False
forms: bool = False
files: bool = True
class AgentCard(BaseModel):
name: str
description: str
version: str
url: str
capabilities: AgentCapabilities
skills: list[AgentSkill]
authentication: Optional[dict[str, Any]] = None
class CodingA2AServer:
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
self.llm_config = LiteLLMConfig(api_key=api_key, model=model or LiteLLMConfig().model)
self.metadata = AgentMetadata()
self.runtime = CodingA2ARuntime(self.llm_config, self.metadata)
self.callback_handler = AgentCallbackHandler(agent_name=POD_NAME, user_id=USER_ID)
self.tasks: dict[str, A2ATask] = {}
self.app = self._create_app()
def _agent_access_required(self) -> bool:
return bool(AGENT_ACCESS_TOKEN)
def _agent_authentication_card(self) -> Optional[dict[str, Any]]:
if not self._agent_access_required():
return None
return {
"type": "header",
"header": AGENT_ACCESS_HEADER,
"required": True,
"agent_id": HEICODE_AGENT_ID or POD_NAME,
}
def _authorize_agent_request(self, request: Request) -> Optional[JSONResponse]:
expected_token = AGENT_ACCESS_TOKEN
if not expected_token:
return None
provided_token = request.headers.get(AGENT_ACCESS_HEADER, "")
if not provided_token:
return JSONResponse(
status_code=401,
content={"detail": f"missing {AGENT_ACCESS_HEADER}"},
)
if not secrets.compare_digest(expected_token, provided_token):
return JSONResponse(
status_code=403,
content={"detail": "agent access denied"},
)
return None
def _jsonrpc_error(
self,
request_id: str,
code: int,
message: str,
*,
data: Optional[dict[str, Any]] = None,
status_code: int = 200,
) -> JSONResponse:
payload: dict[str, Any] = {
"jsonrpc": "2.0",
"id": request_id,
"error": {
"code": code,
"message": message,
},
}
if data is not None:
payload["error"]["data"] = data
return JSONResponse(payload, status_code=status_code)
def _create_app(self) -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
app = FastAPI(
title=f"{self.metadata.name} - A2A",
version=self.metadata.version,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
self._register_routes(app)
return app
def _register_routes(self, app: FastAPI):
@app.get("/")
async def root():
runtime_defaults = CodingRequestConfig()
return {
"name": self.metadata.name,
"version": self.metadata.version,
"protocol": "A2A",
"status": "running",
"pod_name": POD_NAME,
"template_type": TEMPLATE_TYPE,
"role_name": self.metadata.role_name,
"instruction_source": self.metadata.instruction_source,
"enabled_resources": runtime_defaults.resources.enabled_resource_names,
"auth_required": self._agent_access_required(),
"agent_id": HEICODE_AGENT_ID or POD_NAME,
}
@app.get("/health")
async def health():
runtime_defaults = CodingRequestConfig()
return {
"status": "healthy",
"template_type": TEMPLATE_TYPE,
"role_name": self.metadata.role_name,
"instruction_source": self.metadata.instruction_source,
"enabled_resources": runtime_defaults.resources.enabled_resource_names,
"auth_required": self._agent_access_required(),
"agent_id": HEICODE_AGENT_ID or POD_NAME,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
@app.get("/.well-known/agent.json")
async def agent_card(request: Request):
base_url = str(request.base_url).rstrip("/")
card = AgentCard(
name=self.metadata.name,
description=self.metadata.description,
version=self.metadata.version,
url=base_url,
capabilities=AgentCapabilities(streaming=self.metadata.enable_streaming),
authentication=self._agent_authentication_card(),
skills=[
AgentSkill(id="coding", name="Coding", description="Inspect, edit, and verify repositories like a Claude Code style coding agent."),
AgentSkill(id="git", name="Git", description="Prepare workspaces, inspect git state, branch, commit, and push for Gitea, GitHub, and GitLab."),
AgentSkill(id="data", name="Data Resources", description="Inspect MySQL/PostgreSQL schemas and Azure Blob artifacts when configured."),
],
)
return card.model_dump()
@app.post("/message/send")
async def message_send(request: Request):
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
body = await request.json()
try:
rpc_request = A2ARequest(**body)
except Exception as exc:
return self._jsonrpc_error(
str(body.get("id", "unknown")),
-32600,
"Invalid Request",
data={"detail": str(exc)},
)
if rpc_request.method != "message/send":
return self._jsonrpc_error(
rpc_request.id,
-32601,
f"Method not found: {rpc_request.method}",
)
return await self._handle_message_send(rpc_request)
@app.post("/message/stream")
async def message_stream(request: Request):
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
body = await request.json()
try:
rpc_request = A2ARequest(**body)
except Exception as exc:
return self._jsonrpc_error(
str(body.get("id", "unknown")),
-32600,
"Invalid Request",
data={"detail": str(exc)},
)
if rpc_request.method != "message/stream":
return self._jsonrpc_error(
rpc_request.id,
-32601,
f"Method not found: {rpc_request.method}",
)
return await self._handle_message_stream(rpc_request)
@app.get("/tasks/{task_id}")
async def get_task(task_id: str, request: Request):
auth_error = self._authorize_agent_request(request)
if auth_error:
return auth_error
if task_id not in self.tasks:
raise HTTPException(status_code=404, detail="Task not found")
return self.tasks[task_id].model_dump()
async def _handle_message_send(self, request: A2ARequest) -> JSONResponse:
task: Optional[A2ATask] = None
try:
params = request.params or {}
message_text = self._extract_message_text(params.get("message", {}))
if not message_text:
return self._jsonrpc_error(
request.id,
-32602,
"Invalid params: no text content found",
)
task_id = uuid.uuid4().hex
context_id = params.get("contextId", uuid.uuid4().hex)
task = A2ATask(id=task_id, contextId=context_id, status=A2ATaskStatus(state="working"))
self.tasks[task_id] = task
runtime_config = CodingRequestConfig.model_validate(params.get("configuration") or {})
api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key
model = params.get("model") or runtime_config.model or self.llm_config.model
with CallbackContextManager(
handler=self.callback_handler,
user_id=params.get("user_id") or USER_ID,
request_id=task_id,
) as callback:
callback.add_tool("a2a_message_send")
result = await self.runtime.run_task(
message_text,
runtime_config,
api_key=api_key,
model=model,
)
task.status = A2ATaskStatus(state="completed")
task.artifacts = [
A2AArtifact(
name="coding-result",
parts=[A2APart(kind="text", text=result.response_text)],
)
]
task.metadata = {
"summary": result.summary,
"workspace_root": result.workspace_root,
"files_changed": result.files_changed,
"tool_log": [entry.model_dump() for entry in result.tool_log],
"resources_used": result.resources_used,
"role_name": self.metadata.role_name,
"instruction_source": self.metadata.instruction_source,
}
self.tasks[task.id] = task
return JSONResponse({"jsonrpc": "2.0", "id": request.id, "result": task.model_dump()})
except ValidationError as exc:
if task:
task.status = A2ATaskStatus(state="failed", message="Invalid configuration")
self.tasks[task.id] = task
return self._jsonrpc_error(
request.id,
-32602,
"Invalid params: configuration validation failed",
data={"stage": "configuration_validation", "errors": exc.errors()},
)
except CodingRuntimeError as exc:
if task:
task.status = A2ATaskStatus(state="failed", message=str(exc))
self.tasks[task.id] = task
return self._jsonrpc_error(
request.id,
-32010,
str(exc),
data=exc.to_payload(),
)
except Exception as exc:
if task:
task.status = A2ATaskStatus(state="failed", message=str(exc))
self.tasks[task.id] = task
return self._jsonrpc_error(
request.id,
-32000,
f"Agent error: {exc}",
data={"stage": "run_task"},
)
async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse | JSONResponse:
params = request.params or {}
message_text = self._extract_message_text(params.get("message", {}))
if not message_text:
return self._jsonrpc_error(
request.id,
-32602,
"Invalid params: no text content found",
)
try:
runtime_config = CodingRequestConfig.model_validate(params.get("configuration") or {})
except ValidationError as exc:
return self._jsonrpc_error(
request.id,
-32602,
"Invalid params: configuration validation failed",
data={"stage": "configuration_validation", "errors": exc.errors()},
)
api_key = params.get("api_key") or runtime_config.api_key or self.llm_config.api_key
model = params.get("model") or runtime_config.model or self.llm_config.model
task_id = uuid.uuid4().hex
context_id = params.get("contextId", uuid.uuid4().hex)
async def event_stream() -> AsyncGenerator[str, None]:
start_event = {"kind": "task-start", "taskId": task_id, "contextId": context_id}
yield f"data: {json.dumps(start_event, ensure_ascii=False)}\n\n"
try:
result = await self.runtime.run_task(
message_text,
runtime_config,
api_key=api_key,
model=model,
)
artifact_event = {
"kind": "artifact",
"taskId": task_id,
"contextId": context_id,
"data": {
"text": result.response_text,
"summary": result.summary,
"files_changed": result.files_changed,
},
}
yield f"data: {json.dumps(artifact_event, ensure_ascii=False)}\n\n"
finish_event = {"kind": "task-complete", "taskId": task_id, "contextId": context_id}
yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
except CodingRuntimeError as exc:
error_event = {
"kind": "task-failed",
"taskId": task_id,
"contextId": context_id,
"data": {
"message": str(exc),
**exc.to_payload(),
},
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
except Exception as exc:
error_event = {
"kind": "task-failed",
"taskId": task_id,
"contextId": context_id,
"data": {"message": str(exc)},
}
yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
def _extract_message_text(self, message: dict[str, Any]) -> str:
parts = message.get("parts", [])
return "".join(part.get("text", "") for part in parts if part.get("kind") == "text")
def create_app(api_key: Optional[str] = None, model: Optional[str] = None) -> FastAPI:
return CodingA2AServer(api_key=api_key, model=model).app
@@ -0,0 +1,562 @@
"""
Pydantic AI powered coding runtime with Claude Code style tools.
"""
from __future__ import annotations
import json
import os
import shlex
import subprocess
from pathlib import Path
from typing import Any, Optional
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
from .config import (
AgentMetadata,
AzureBlobResourceConfig,
CodingRequestConfig,
DatabaseEngine,
DatabaseResourceConfig,
GitResourceConfig,
LiteLLMConfig,
ResourceConfig,
WorkspaceConfig,
)
from .resources import build_authenticated_repo_url, safe_workspace_path, summarize_resources
class ToolEvent(BaseModel):
tool: str
payload: dict[str, Any] = Field(default_factory=dict)
class CodingRunContext(BaseModel):
workspace: WorkspaceConfig
resources: ResourceConfig
changed_files: list[str] = Field(default_factory=list)
tool_log: list[ToolEvent] = Field(default_factory=list)
finish_summary: Optional[str] = None
model_config = {"arbitrary_types_allowed": True}
class CodingRunResult(BaseModel):
response_text: str
summary: str
workspace_root: str
files_changed: list[str] = Field(default_factory=list)
tool_log: list[ToolEvent] = Field(default_factory=list)
resources_used: dict[str, Any] = Field(default_factory=dict)
class CodingRuntimeError(Exception):
"""Structured runtime error that can be returned through JSON-RPC."""
def __init__(
self,
message: str,
*,
code: str = "runtime_error",
stage: str = "runtime",
data: Optional[dict[str, Any]] = None,
):
super().__init__(message)
self.code = code
self.stage = stage
self.data = data or {}
def to_payload(self) -> dict[str, Any]:
payload = {
"code": self.code,
"stage": self.stage,
}
payload.update(self.data)
return payload
class WorkspacePreparationError(CodingRuntimeError):
"""Raised when the remote workspace cannot be prepared safely."""
class CodingA2ARuntime:
def __init__(
self,
llm_config: LiteLLMConfig,
metadata: AgentMetadata,
):
self.llm_config = llm_config
self.metadata = metadata
self._apply_llm_env(self.llm_config)
self._agent = self._build_agent(self.llm_config)
def _build_agent(self, llm_config: LiteLLMConfig) -> Agent:
agent: Agent[CodingRunContext] = Agent(
llm_config.normalized_model,
system_prompt=self.metadata.effective_system_prompt,
deps_type=CodingRunContext,
)
@agent.tool
async def read_file(ctx: RunContext[CodingRunContext], path: str) -> str:
target = safe_workspace_path(
ctx.deps.workspace.root_dir,
path,
ctx.deps.workspace.allowed_paths or ctx.deps.resources.git.allowed_paths if ctx.deps.resources.git else ctx.deps.workspace.allowed_paths,
)
content = target.read_text(encoding="utf-8", errors="replace")
ctx.deps.tool_log.append(ToolEvent(tool="read_file", payload={"path": path, "bytes": len(content)}))
return content
@agent.tool
async def list_files(ctx: RunContext[CodingRunContext], glob_pattern: str = "**/*") -> str:
root = Path(ctx.deps.workspace.root_dir).resolve()
matched = [
path.relative_to(root).as_posix()
for path in sorted(root.glob(glob_pattern))
if path.is_file()
]
ctx.deps.tool_log.append(ToolEvent(tool="list_files", payload={"pattern": glob_pattern, "count": len(matched)}))
return "\n".join(matched[:500]) if matched else "(no files matched)"
@agent.tool
async def write_file(ctx: RunContext[CodingRunContext], path: str, content: str) -> str:
target = safe_workspace_path(
ctx.deps.workspace.root_dir,
path,
ctx.deps.workspace.allowed_paths or ctx.deps.resources.git.allowed_paths if ctx.deps.resources.git else ctx.deps.workspace.allowed_paths,
)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
rel = target.relative_to(Path(ctx.deps.workspace.root_dir).resolve()).as_posix()
if rel not in ctx.deps.changed_files:
ctx.deps.changed_files.append(rel)
ctx.deps.tool_log.append(ToolEvent(tool="write_file", payload={"path": rel, "bytes": len(content.encode())}))
return f"written {rel}"
@agent.tool
async def edit_file(ctx: RunContext[CodingRunContext], path: str, old_text: str, new_text: str) -> str:
target = safe_workspace_path(
ctx.deps.workspace.root_dir,
path,
ctx.deps.workspace.allowed_paths or ctx.deps.resources.git.allowed_paths if ctx.deps.resources.git else ctx.deps.workspace.allowed_paths,
)
content = target.read_text(encoding="utf-8", errors="replace")
if old_text not in content:
return "old_text not found"
updated = content.replace(old_text, new_text, 1)
target.write_text(updated, encoding="utf-8")
rel = target.relative_to(Path(ctx.deps.workspace.root_dir).resolve()).as_posix()
if rel not in ctx.deps.changed_files:
ctx.deps.changed_files.append(rel)
ctx.deps.tool_log.append(ToolEvent(tool="edit_file", payload={"path": rel}))
return f"edited {rel}"
@agent.tool
async def run_command(ctx: RunContext[CodingRunContext], command: str) -> str:
completed = subprocess.run(
["bash", "-lc", command],
cwd=ctx.deps.workspace.root_dir,
capture_output=True,
text=True,
timeout=300,
)
ctx.deps.tool_log.append(
ToolEvent(
tool="run_command",
payload={"command": command, "returncode": completed.returncode},
)
)
output = completed.stdout.strip()
if completed.stderr.strip():
output = f"{output}\n{completed.stderr.strip()}".strip()
return output or f"(exit code {completed.returncode})"
@agent.tool
async def git_prepare_workspace(ctx: RunContext[CodingRunContext]) -> str:
git = ctx.deps.resources.git
if not git or not git.repo_url:
return "git resource not configured"
workspace = Path(ctx.deps.workspace.root_dir)
if (workspace / ".git").exists():
return "workspace already contains a git repository"
workspace.parent.mkdir(parents=True, exist_ok=True)
auth_url = build_authenticated_repo_url(
git.repo_url,
username=git.username,
password=git.password,
token=git.token,
)
branch = git.default_branch or "main"
completed = subprocess.run(
["git", "clone", "--branch", branch, auth_url, workspace.name],
cwd=str(workspace.parent),
capture_output=True,
text=True,
timeout=300,
)
ctx.deps.tool_log.append(
ToolEvent(
tool="git_prepare_workspace",
payload={"repo_url": git.repo_url, "returncode": completed.returncode},
)
)
if completed.returncode != 0:
return (completed.stdout + "\n" + completed.stderr).strip()
return f"cloned {git.repo_url} into {workspace}"
@agent.tool
async def git_status(ctx: RunContext[CodingRunContext]) -> str:
return await run_command(ctx, "git status --short")
@agent.tool
async def git_diff(ctx: RunContext[CodingRunContext], ref: str = "HEAD") -> str:
return await run_command(ctx, f"git diff {ref}")
@agent.tool
async def git_create_branch(ctx: RunContext[CodingRunContext], branch_name: str) -> str:
return await run_command(ctx, f"git checkout -B {branch_name}")
@agent.tool
async def git_commit(ctx: RunContext[CodingRunContext], message: str) -> str:
await run_command(ctx, "git add -A")
return await run_command(ctx, f"git commit -m {shlex.quote(message)}")
@agent.tool
async def git_push(ctx: RunContext[CodingRunContext], remote: str = "origin", branch_name: Optional[str] = None) -> str:
git = ctx.deps.resources.git
if git and git.repo_url:
auth_url = build_authenticated_repo_url(
git.repo_url,
username=git.username,
password=git.password,
token=git.token,
)
subprocess.run(
["git", "remote", "set-url", remote, auth_url],
cwd=ctx.deps.workspace.root_dir,
capture_output=True,
text=True,
timeout=60,
)
target = branch_name or "HEAD"
return await run_command(ctx, f"git push {remote} {target}")
@agent.tool
async def list_database_tables(ctx: RunContext[CodingRunContext], engine: str = "postgresql") -> str:
config = self._select_database_config(ctx.deps.resources, engine)
if not config:
return f"{engine} resource not configured"
result = self._run_database_query(config, self._default_table_query(config.engine))
ctx.deps.tool_log.append(ToolEvent(tool="list_database_tables", payload={"engine": config.engine.value}))
return result
@agent.tool
async def run_database_query(ctx: RunContext[CodingRunContext], engine: str, query: str) -> str:
config = self._select_database_config(ctx.deps.resources, engine)
if not config:
return f"{engine} resource not configured"
result = self._run_database_query(config, query)
ctx.deps.tool_log.append(ToolEvent(tool="run_database_query", payload={"engine": config.engine.value}))
return result
@agent.tool
async def list_blob_objects(ctx: RunContext[CodingRunContext], limit: int = 50) -> str:
config = ctx.deps.resources.azure_blob
if not config:
return "azure_blob resource not configured"
result = self._list_blob_objects(config, limit=limit)
ctx.deps.tool_log.append(ToolEvent(tool="list_blob_objects", payload={"count": len(result)}))
return json.dumps(result, ensure_ascii=False, indent=2)
@agent.tool
async def read_blob_text(ctx: RunContext[CodingRunContext], blob_name: str, encoding: str = "utf-8") -> str:
config = ctx.deps.resources.azure_blob
if not config:
return "azure_blob resource not configured"
text = self._read_blob_text(config, blob_name=blob_name, encoding=encoding)
ctx.deps.tool_log.append(ToolEvent(tool="read_blob_text", payload={"blob_name": blob_name, "bytes": len(text.encode())}))
return text
@agent.tool
async def finish(ctx: RunContext[CodingRunContext], summary: str) -> str:
ctx.deps.finish_summary = summary
ctx.deps.tool_log.append(ToolEvent(tool="finish", payload={"summary": summary}))
return f"done: {summary}"
return agent
async def run_task(
self,
prompt: str,
request_config: CodingRequestConfig,
*,
api_key: Optional[str] = None,
model: Optional[str] = None,
) -> CodingRunResult:
llm_config = LiteLLMConfig(
base_url=self.llm_config.base_url,
api_key=api_key or request_config.api_key or self.llm_config.api_key,
model=model or request_config.model or self.llm_config.model,
timeout=self.llm_config.timeout,
max_tokens=self.llm_config.max_tokens,
)
previous_api_key = os.environ.get("OPENAI_API_KEY")
previous_base_url = os.environ.get("OPENAI_BASE_URL")
self._apply_llm_env(llm_config)
agent = self._build_agent(llm_config)
deps = CodingRunContext(workspace=request_config.workspace, resources=request_config.resources)
self._prepare_workspace(request_config, deps)
initial_prompt = self._build_initial_prompt(prompt, request_config)
try:
result = await agent.run(initial_prompt, deps=deps)
response_text = getattr(result, "output", None) or getattr(result, "data", None) or str(result)
summary = deps.finish_summary or response_text
return CodingRunResult(
response_text=str(response_text),
summary=summary,
workspace_root=request_config.workspace.root_dir,
files_changed=deps.changed_files,
tool_log=deps.tool_log,
resources_used=summarize_resources(request_config.resources.model_dump(exclude_none=True)),
)
finally:
if previous_api_key is None:
os.environ.pop("OPENAI_API_KEY", None)
else:
os.environ["OPENAI_API_KEY"] = previous_api_key
if previous_base_url is None:
os.environ.pop("OPENAI_BASE_URL", None)
else:
os.environ["OPENAI_BASE_URL"] = previous_base_url
def _apply_llm_env(self, llm_config: LiteLLMConfig) -> None:
if llm_config.api_key:
os.environ["OPENAI_API_KEY"] = llm_config.api_key
os.environ["OPENAI_BASE_URL"] = llm_config.base_url
def _prepare_workspace(
self,
request_config: CodingRequestConfig,
deps: CodingRunContext,
) -> None:
workspace_root = Path(request_config.workspace.root_dir).expanduser().resolve()
request_config.workspace.root_dir = str(workspace_root)
deps.workspace.root_dir = str(workspace_root)
if workspace_root.exists() and not workspace_root.is_dir():
raise WorkspacePreparationError(
"workspace root is not a directory",
code="workspace_not_directory",
stage="workspace_prepare",
data={"workspace_root": str(workspace_root)},
)
git = request_config.resources.git
if git and git.repo_url:
self._prepare_git_workspace(workspace_root, git, deps)
return
workspace_root.mkdir(parents=True, exist_ok=True)
deps.tool_log.append(
ToolEvent(
tool="workspace_prepare",
payload={"workspace_root": str(workspace_root), "mode": "empty_workspace"},
)
)
def _prepare_git_workspace(
self,
workspace_root: Path,
git: GitResourceConfig,
deps: CodingRunContext,
) -> None:
if (workspace_root / ".git").exists():
deps.tool_log.append(
ToolEvent(
tool="git_prepare_workspace",
payload={
"workspace_root": str(workspace_root),
"repo_url": git.repo_url,
"mode": "existing_repository",
},
)
)
return
workspace_root.parent.mkdir(parents=True, exist_ok=True)
if workspace_root.exists():
if not workspace_root.is_dir():
raise WorkspacePreparationError(
"workspace root is not a directory",
code="workspace_not_directory",
stage="git_prepare_workspace",
data={"workspace_root": str(workspace_root), "repo_url": git.repo_url},
)
if any(workspace_root.iterdir()):
raise WorkspacePreparationError(
"workspace already exists but is not a git repository",
code="workspace_not_git_repository",
stage="git_prepare_workspace",
data={"workspace_root": str(workspace_root), "repo_url": git.repo_url},
)
workspace_root.rmdir()
auth_url = build_authenticated_repo_url(
git.repo_url,
username=git.username,
password=git.password,
token=git.token,
)
branch = git.default_branch or "main"
try:
completed = subprocess.run(
["git", "clone", "--branch", branch, auth_url, workspace_root.name],
cwd=str(workspace_root.parent),
capture_output=True,
text=True,
timeout=180,
)
except subprocess.TimeoutExpired as exc:
raise WorkspacePreparationError(
"git workspace preparation timed out",
code="git_prepare_timeout",
stage="git_prepare_workspace",
data={
"workspace_root": str(workspace_root),
"repo_url": git.repo_url,
"branch": branch,
"timeout_seconds": exc.timeout,
},
) from exc
payload = {
"workspace_root": str(workspace_root),
"repo_url": git.repo_url,
"branch": branch,
"returncode": completed.returncode,
}
deps.tool_log.append(ToolEvent(tool="git_prepare_workspace", payload=payload))
if completed.returncode != 0:
raise WorkspacePreparationError(
"git workspace preparation failed",
code="git_prepare_failed",
stage="git_prepare_workspace",
data={
**payload,
"stdout": completed.stdout.strip(),
"stderr": completed.stderr.strip(),
},
)
def _build_initial_prompt(self, prompt: str, request_config: CodingRequestConfig) -> str:
workspace = request_config.workspace
parts = [
f"USER TASK:\n{prompt}",
f"WORKSPACE ROOT: {workspace.root_dir}",
f"TASK MODE: {request_config.task_mode}",
]
if workspace.entry_file:
parts.append(f"START BY READING: {workspace.entry_file}")
if workspace.context_files:
parts.append("ALSO CONSIDER: " + ", ".join(workspace.context_files))
if workspace.allowed_paths:
parts.append("YOU MAY ONLY MODIFY: " + ", ".join(workspace.allowed_paths))
if request_config.branch_name:
parts.append(f"PREFERRED BRANCH: {request_config.branch_name}")
if request_config.commit_message:
parts.append(f"SUGGESTED COMMIT MESSAGE: {request_config.commit_message}")
parts.append(
"Use tools to inspect before editing. Prefer minimal precise changes. "
"When the work is complete, call finish(summary)."
)
return "\n\n".join(parts)
def _select_database_config(self, resources: ResourceConfig, engine: str) -> Optional[DatabaseResourceConfig]:
requested = engine.lower()
if requested in {"mysql", "mariadb"}:
return resources.mysql
return resources.postgresql
def _default_table_query(self, engine: DatabaseEngine) -> str:
if engine == DatabaseEngine.mysql:
return "SHOW TABLES"
return (
"SELECT table_schema, table_name FROM information_schema.tables "
"WHERE table_schema NOT IN ('pg_catalog', 'information_schema') "
"ORDER BY table_schema, table_name LIMIT 200"
)
def _run_database_query(self, config: DatabaseResourceConfig, query: str) -> str:
if config.engine == DatabaseEngine.mysql:
import pymysql
connection = pymysql.connect(
host=config.host,
port=config.port or 3306,
user=config.username,
password=config.password,
database=config.database,
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=10,
)
else:
import psycopg2
import psycopg2.extras
connection = psycopg2.connect(
host=config.host,
port=config.port or 5432,
user=config.username,
password=config.password,
dbname=config.database,
connect_timeout=10,
sslmode=config.ssl_mode or "prefer",
)
try:
with connection.cursor() as cursor:
cursor.execute(query)
rows = cursor.fetchall()
return json.dumps(rows, ensure_ascii=False, default=str, indent=2)
finally:
connection.close()
def _get_blob_client(self, config: AzureBlobResourceConfig):
from azure.storage.blob import BlobServiceClient
if config.connection_string:
return BlobServiceClient.from_connection_string(config.connection_string)
if config.account_url and config.sas_token:
return BlobServiceClient(account_url=config.account_url, credential=config.sas_token)
if config.account_url and config.account_key:
return BlobServiceClient(account_url=config.account_url, credential=config.account_key)
if config.account_name and config.account_key:
account_url = f"https://{config.account_name}.blob.core.windows.net"
return BlobServiceClient(account_url=account_url, credential=config.account_key)
raise ValueError("azure blob credentials are not configured")
def _list_blob_objects(self, config: AzureBlobResourceConfig, limit: int = 50) -> list[dict[str, Any]]:
service = self._get_blob_client(config)
container = service.get_container_client(config.container_name)
items = []
for index, blob in enumerate(container.list_blobs(name_starts_with=config.prefix or None)):
if index >= limit:
break
items.append(
{
"name": blob.name,
"size": blob.size,
"content_type": getattr(blob.content_settings, "content_type", None),
}
)
return items
def _read_blob_text(self, config: AzureBlobResourceConfig, blob_name: str, encoding: str = "utf-8") -> str:
service = self._get_blob_client(config)
blob_client = service.get_blob_client(container=config.container_name, blob=blob_name)
return blob_client.download_blob().readall().decode(encoding, errors="replace")
@@ -0,0 +1,31 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
curl \
git \
bash \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY agents/coding_a2a_agent/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY agents/coding_a2a_agent /app/coding_a2a_agent
ENV SERVICE_HOST=0.0.0.0
ENV SERVICE_PORT=8000
ENV POD_NAME=coding-a2a-agent
ENV TEMPLATE_TYPE=coding_a2a_agent
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
ENV WORK_DIR=/workspace
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()" || exit 1
EXPOSE 8000
CMD ["python", "-m", "coding_a2a_agent.main"]
@@ -0,0 +1,3 @@
"""
Common helpers for the coding A2A agent.
"""
@@ -0,0 +1,120 @@
"""
Agent回调工具 - 用于向Agent Manager回调运行时长记录
"""
import os
import time
import logging
import requests
from typing import Optional, List
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
class AgentCallbackHandler:
"""Agent回调处理器"""
def __init__(
self,
agent_name: Optional[str] = None,
user_id: Optional[str] = None,
callback_url: Optional[str] = None
):
self.agent_name = agent_name or os.getenv("POD_NAME", "unknown-agent")
self.user_id = user_id or os.getenv("USER_ID", "")
self.callback_url = callback_url or os.getenv(
"AGENT_CALLBACK_URL",
"http://mcp-server.taiji-ai.svc.cluster.local:8000/api/v1/billing/agent-callback"
)
self.start_time: Optional[datetime] = None
self.tools_used: List[str] = []
self.request_id: Optional[str] = None
logger.info(
"AgentCallbackHandler initialized: agent=%s callback_url=%s",
self.agent_name,
self.callback_url,
)
def start_request(self, request_id: Optional[str] = None, user_id: Optional[str] = None):
self.start_time = datetime.now(timezone.utc)
self.tools_used = []
self.request_id = request_id or f"req-{int(time.time())}"
if user_id:
self.user_id = user_id
def add_tool_used(self, tool_name: str):
if tool_name not in self.tools_used:
self.tools_used.append(tool_name)
def end_request(self, tools_used: Optional[List[str]] = None) -> bool:
if not self.start_time or not self.user_id:
return False
end_time = datetime.now(timezone.utc)
running_time = (end_time - self.start_time).total_seconds()
final_tools_used = tools_used if tools_used is not None else self.tools_used
success = self._send_callback(
running_time_seconds=int(running_time),
start_time=self.start_time,
end_time=end_time,
tools_used=final_tools_used,
)
self.start_time = None
self.tools_used = []
self.request_id = None
return success
def _send_callback(
self,
running_time_seconds: int,
start_time: datetime,
end_time: datetime,
tools_used: List[str]
) -> bool:
try:
payload = {
"agentName": self.agent_name,
"userId": self.user_id,
"podRunningTimeSeconds": running_time_seconds,
"toolsUsed": tools_used,
"startTime": start_time.isoformat(),
"endTime": end_time.isoformat(),
"requestId": self.request_id,
}
response = requests.post(self.callback_url, json=payload, timeout=5)
return response.status_code == 200
except Exception:
return False
class CallbackContextManager:
"""回调上下文管理器 - 使用with语句自动处理开始和结束"""
def __init__(
self,
handler: AgentCallbackHandler,
request_id: Optional[str] = None,
user_id: Optional[str] = None,
tools_used: Optional[List[str]] = None
):
self.handler = handler
self.request_id = request_id
self.user_id = user_id
self.tools_used = tools_used or []
def __enter__(self):
self.handler.start_request(request_id=self.request_id, user_id=self.user_id)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.handler.end_request(tools_used=self.tools_used)
return False
def add_tool(self, tool_name: str):
self.handler.add_tool_used(tool_name)
if tool_name not in self.tools_used:
self.tools_used.append(tool_name)
@@ -0,0 +1,346 @@
"""
Configuration models for the coding A2A agent.
"""
from __future__ import annotations
import os
from pathlib import Path
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field, model_validator
class GitProvider(str, Enum):
gitea = "gitea"
github = "github"
gitlab = "gitlab"
generic = "generic"
class DatabaseEngine(str, Enum):
mysql = "mysql"
postgresql = "postgresql"
def _env_text(*names: str) -> Optional[str]:
for name in names:
value = os.getenv(name)
if value is not None and value != "":
return value
return None
def _env_int(*names: str) -> Optional[int]:
value = _env_text(*names)
return int(value) if value is not None else None
def _env_list(*names: str) -> list[str]:
value = _env_text(*names)
if not value:
return []
return [item.strip() for item in value.split(",") if item.strip()]
class LiteLLMConfig(BaseModel):
base_url: str = Field(
default_factory=lambda: (
os.getenv("LITELLM_BASE_URL")
or os.getenv("LLM_BASE_URL")
or os.getenv("OPENAI_BASE_URL")
or "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1"
).rstrip("/")
)
api_key: Optional[str] = Field(default_factory=lambda: os.getenv("LITELLM_API_KEY") or os.getenv("OPENAI_API_KEY"))
model: str = Field(
default_factory=lambda: (
os.getenv("MODEL_NAME")
or os.getenv("LITELLM_MODEL")
or os.getenv("LLM_MODEL")
or "taiji/gpt-4o-mini"
)
)
timeout: int = Field(default_factory=lambda: int(os.getenv("LITELLM_TIMEOUT") or os.getenv("LLM_TIMEOUT") or "600"))
max_tokens: int = Field(default_factory=lambda: int(os.getenv("LITELLM_MAX_TOKENS") or os.getenv("LLM_MAX_TOKENS") or "4096"))
@property
def normalized_model(self) -> str:
if ":" in self.model:
return self.model
return f"openai:{self.model}"
class WorkspaceConfig(BaseModel):
root_dir: str = Field(default_factory=lambda: os.getenv("WORK_DIR", "/workspace"))
entry_file: Optional[str] = None
context_files: list[str] = Field(default_factory=list)
allowed_paths: list[str] = Field(default_factory=list)
class GitResourceConfig(BaseModel):
provider: Optional[GitProvider] = None
repo_url: Optional[str] = None
default_branch: str = "main"
username: Optional[str] = None
password: Optional[str] = None
token: Optional[str] = None
local_path: Optional[str] = None
allowed_paths: list[str] = Field(default_factory=list)
write_mode: str = "branch"
@model_validator(mode="after")
def infer_provider(self) -> "GitResourceConfig":
if self.provider is None and self.repo_url:
lowered = self.repo_url.lower()
if "github" in lowered:
self.provider = GitProvider.github
elif "gitlab" in lowered:
self.provider = GitProvider.gitlab
elif "gitea" in lowered or ":3000/" in lowered or "/api/v1/" in lowered:
self.provider = GitProvider.gitea
else:
self.provider = GitProvider.generic
return self
class DatabaseResourceConfig(BaseModel):
engine: DatabaseEngine
host: str
port: Optional[int] = None
username: str
password: str
database: str
ssl_mode: Optional[str] = None
class AzureBlobResourceConfig(BaseModel):
account_url: Optional[str] = None
connection_string: Optional[str] = None
container_name: str
account_name: Optional[str] = None
account_key: Optional[str] = None
sas_token: Optional[str] = None
prefix: str = ""
class ResourceConfig(BaseModel):
git: Optional[GitResourceConfig] = None
mysql: Optional[DatabaseResourceConfig] = None
postgresql: Optional[DatabaseResourceConfig] = None
azure_blob: Optional[AzureBlobResourceConfig] = None
@model_validator(mode="before")
@classmethod
def apply_env_defaults(cls, data: Any) -> Any:
if isinstance(data, cls):
return data
payload = dict(data or {})
git_env = _git_resource_from_env()
mysql_env = _mysql_resource_from_env()
postgres_env = _postgres_resource_from_env()
blob_env = _azure_blob_resource_from_env()
if "git" not in payload and git_env:
payload["git"] = git_env
elif isinstance(payload.get("git"), dict) and git_env:
payload["git"] = {**git_env, **payload["git"]}
if "mysql" not in payload and mysql_env:
payload["mysql"] = mysql_env
elif isinstance(payload.get("mysql"), dict) and mysql_env:
payload["mysql"] = {**mysql_env, **payload["mysql"]}
if "postgresql" not in payload and postgres_env:
payload["postgresql"] = postgres_env
elif isinstance(payload.get("postgresql"), dict) and postgres_env:
payload["postgresql"] = {**postgres_env, **payload["postgresql"]}
if "azure_blob" not in payload and blob_env:
payload["azure_blob"] = blob_env
elif isinstance(payload.get("azure_blob"), dict) and blob_env:
payload["azure_blob"] = {**blob_env, **payload["azure_blob"]}
return payload
@property
def enabled_resource_names(self) -> list[str]:
names: list[str] = []
if self.git:
names.append("git")
if self.mysql:
names.append("mysql")
if self.postgresql:
names.append("postgresql")
if self.azure_blob:
names.append("azure_blob")
return names
class AgentMetadata(BaseModel):
name: str = Field(default_factory=lambda: os.getenv("AGENT_NAME", "coding-a2a-agent"))
description: str = Field(
default=(
"Claude Code 风格的编程 Agent,使用 Pydantic AI 作为核心,"
"支持 A2A 协议,以及 Git / DB / Azure Blob 资源工具。"
)
)
version: str = "1.0.0"
enable_streaming: bool = True
role_name: Optional[str] = Field(
default_factory=lambda: os.getenv("AGENT_ROLE_NAME") or os.getenv("AGENT_ROLE")
)
instruction_text: Optional[str] = Field(
default_factory=lambda: os.getenv("AGENT_INSTRUCTION_TEXT")
)
instruction_file: Optional[str] = Field(
default_factory=lambda: os.getenv("AGENT_INSTRUCTION_FILE")
)
system_prompt: str = Field(
default=(
"You are a senior coding agent similar to Claude Code. "
"Understand the repository first, then make minimal precise changes. "
"Prefer using tools to inspect, edit, run checks, inspect git state, "
"query configured databases, and inspect Azure Blob artifacts. "
"Always end by calling finish(summary)."
)
)
instruction_source: str = "default"
instruction_content: Optional[str] = None
@model_validator(mode="after")
def load_instruction_content(self) -> "AgentMetadata":
if self.instruction_text and self.instruction_text.strip():
self.instruction_source = "env_text"
self.instruction_content = self.instruction_text.strip()
return self
if self.instruction_file:
instruction_path = Path(self.instruction_file)
if instruction_path.exists() and instruction_path.is_file():
self.instruction_source = f"env_file:{instruction_path}"
self.instruction_content = instruction_path.read_text(
encoding="utf-8",
errors="replace",
).strip()
return self
self.instruction_source = "default"
self.instruction_content = None
return self
@property
def effective_system_prompt(self) -> str:
sections = [self.system_prompt.strip()]
if self.role_name:
sections.append(f"Runtime role assignment: {self.role_name.strip()}")
if self.instruction_content:
sections.append(
"Startup instructions loaded from runtime configuration:\n"
f"{self.instruction_content.strip()}"
)
return "\n\n".join(part for part in sections if part)
class CodingRequestConfig(BaseModel):
workspace: WorkspaceConfig = Field(default_factory=WorkspaceConfig)
resources: ResourceConfig = Field(default_factory=ResourceConfig)
task_mode: str = "code"
branch_name: Optional[str] = None
commit_message: Optional[str] = None
model: Optional[str] = None
api_key: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
def get_runtime_defaults(
api_key: Optional[str] = None,
model: Optional[str] = None,
) -> tuple[LiteLLMConfig, AgentMetadata]:
llm = LiteLLMConfig(api_key=api_key, model=model or LiteLLMConfig().model)
meta = AgentMetadata()
return llm, meta
def _git_resource_from_env() -> Optional[dict[str, Any]]:
repo_url = _env_text("GIT_REPO_URL")
username = _env_text("GIT_USERNAME", "GIT_USER")
password = _env_text("GIT_PASSWORD")
token = _env_text("GIT_TOKEN", "GITHUB_TOKEN", "GITLAB_TOKEN", "GITEA_TOKEN")
provider = _env_text("GIT_PROVIDER")
if not any([repo_url, username, password, token]):
return None
data: dict[str, Any] = {
"repo_url": repo_url,
"username": username,
"password": password,
"token": token,
"provider": provider,
"default_branch": _env_text("GIT_DEFAULT_BRANCH") or "main",
"local_path": _env_text("GIT_LOCAL_PATH"),
"allowed_paths": _env_list("GIT_ALLOWED_PATHS"),
"write_mode": _env_text("GIT_WRITE_MODE") or "branch",
}
return {key: value for key, value in data.items() if value not in (None, [], "")}
def _mysql_resource_from_env() -> Optional[dict[str, Any]]:
host = _env_text("MYSQL_HOST")
username = _env_text("MYSQL_USER", "MYSQL_USERNAME")
password = _env_text("MYSQL_PASSWORD")
database = _env_text("MYSQL_DATABASE", "MYSQL_DB")
if not all([host, username, password, database]):
return None
data: dict[str, Any] = {
"engine": "mysql",
"host": host,
"port": _env_int("MYSQL_PORT"),
"username": username,
"password": password,
"database": database,
"ssl_mode": _env_text("MYSQL_SSL_MODE"),
}
return {key: value for key, value in data.items() if value is not None}
def _postgres_resource_from_env() -> Optional[dict[str, Any]]:
host = _env_text("POSTGRES_HOST", "POSTGRESQL_HOST")
username = _env_text("POSTGRES_USER", "POSTGRES_USERNAME", "POSTGRESQL_USER")
password = _env_text("POSTGRES_PASSWORD", "POSTGRESQL_PASSWORD")
database = _env_text("POSTGRES_DATABASE", "POSTGRES_DB", "POSTGRESQL_DATABASE")
if not all([host, username, password, database]):
return None
data: dict[str, Any] = {
"engine": "postgresql",
"host": host,
"port": _env_int("POSTGRES_PORT", "POSTGRESQL_PORT"),
"username": username,
"password": password,
"database": database,
"ssl_mode": _env_text("POSTGRES_SSL_MODE", "POSTGRESQL_SSL_MODE"),
}
return {key: value for key, value in data.items() if value is not None}
def _azure_blob_resource_from_env() -> Optional[dict[str, Any]]:
container_name = _env_text("AZURE_BLOB_CONTAINER", "AZURE_STORAGE_CONTAINER")
connection_string = _env_text("AZURE_BLOB_CONNECTION_STRING", "AZURE_STORAGE_CONNECTION_STRING")
account_url = _env_text("AZURE_BLOB_ACCOUNT_URL")
account_name = _env_text("AZURE_BLOB_ACCOUNT_NAME", "AZURE_STORAGE_ACCOUNT_NAME")
account_key = _env_text("AZURE_BLOB_ACCOUNT_KEY", "AZURE_STORAGE_ACCOUNT_KEY")
sas_token = _env_text("AZURE_BLOB_SAS_TOKEN")
if not container_name:
return None
if not any([connection_string, account_url, account_name]):
return None
data: dict[str, Any] = {
"container_name": container_name,
"connection_string": connection_string,
"account_url": account_url,
"account_name": account_name,
"account_key": account_key,
"sas_token": sas_token,
"prefix": _env_text("AZURE_BLOB_PREFIX", "AZURE_STORAGE_PREFIX") or "",
}
return {key: value for key, value in data.items() if value not in (None, "")}
@@ -0,0 +1,25 @@
"""
Coding A2A Agent entrypoint.
"""
import os
import uvicorn
from coding_a2a_agent.a2a_server import create_app
SERVICE_HOST = os.getenv("SERVICE_HOST", "0.0.0.0")
SERVICE_PORT = int(os.getenv("SERVICE_PORT", "8000"))
app = create_app(
api_key=os.getenv("LITELLM_API_KEY") or os.getenv("OPENAI_API_KEY"),
model=os.getenv("MODEL_NAME") or os.getenv("LITELLM_MODEL"),
)
def main():
uvicorn.run(app, host=SERVICE_HOST, port=SERVICE_PORT, log_level="info")
if __name__ == "__main__":
main()
@@ -0,0 +1,10 @@
pydantic-ai-slim[openai]>=0.0.14
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
pydantic>=2.7.0
python-dotenv>=1.0.0
httpx>=0.27.0
requests>=2.31.0
azure-storage-blob>=12.19.0
psycopg2-binary>=2.9.9
PyMySQL>=1.1.1
@@ -0,0 +1,85 @@
"""
Shared resource helpers for the coding A2A agent.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from urllib.parse import quote
from .config import GitProvider
def ensure_model_prefix(model_name: str) -> str:
return model_name if ":" in model_name else f"openai:{model_name}"
def detect_git_provider(repo_url: str) -> GitProvider:
lowered = repo_url.lower()
if "github" in lowered:
return GitProvider.github
if "gitlab" in lowered:
return GitProvider.gitlab
if "gitea" in lowered or ":3000/" in lowered:
return GitProvider.gitea
return GitProvider.generic
def build_authenticated_repo_url(
repo_url: str,
username: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
) -> str:
if not repo_url.startswith(("http://", "https://")):
return repo_url
provider = detect_git_provider(repo_url)
if token and not username:
if provider == GitProvider.github:
username = "x-access-token"
elif provider == GitProvider.gitlab:
username = "oauth2"
else:
username = "git"
password = token
elif token and username and not password:
password = token
if not username or not password:
return repo_url
encoded_user = quote(username, safe="")
encoded_password = quote(password, safe="")
if repo_url.startswith("https://"):
return repo_url.replace("https://", f"https://{encoded_user}:{encoded_password}@", 1)
return repo_url.replace("http://", f"http://{encoded_user}:{encoded_password}@", 1)
def safe_workspace_path(root_dir: str, relative_path: str, allowed_paths: Optional[list[str]] = None) -> Path:
root = Path(root_dir).resolve()
candidate = (root / relative_path).resolve()
if candidate != root and root not in candidate.parents:
raise ValueError(f"path escapes workspace: {relative_path}")
if ".git" in candidate.parts:
raise ValueError("access to .git is not allowed")
if ".github" in candidate.parts and "workflows" in candidate.parts:
raise ValueError("access to .github/workflows is not allowed")
if allowed_paths:
normalized = candidate.relative_to(root).as_posix()
if not any(
normalized == path.strip("/")
or normalized.startswith(f"{path.strip('/')}/")
for path in allowed_paths
):
raise ValueError(f"path outside allowed_paths: {relative_path}")
return candidate
def summarize_resources(resources: dict) -> dict:
summary = {}
for key, value in resources.items():
if not value:
continue
if isinstance(value, dict):
summary[key] = sorted(value.keys())
else:
summary[key] = str(type(value).__name__)
return summary
@@ -0,0 +1,241 @@
import unittest
import sys
import tempfile
import json
import asyncio
import subprocess
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, GitProvider, LiteLLMConfig
from coding_a2a_agent.resources import (
build_authenticated_repo_url,
detect_git_provider,
ensure_model_prefix,
safe_workspace_path,
)
RUNTIME_IMPORT_ERROR = None
try:
from coding_a2a_agent.a2a_server import A2ARequest, CodingA2AServer
from coding_a2a_agent.agent import CodingA2ARuntime, CodingRunContext, WorkspacePreparationError
except ModuleNotFoundError as exc: # pragma: no cover - depends on optional runtime deps in local env
RUNTIME_IMPORT_ERROR = exc
A2ARequest = None
CodingA2AServer = None
CodingA2ARuntime = None
CodingRunContext = None
WorkspacePreparationError = None
class CodingA2AHelperTests(unittest.TestCase):
def _make_runtime(self) -> CodingA2ARuntime:
if RUNTIME_IMPORT_ERROR is not None:
self.skipTest(f"runtime dependencies unavailable: {RUNTIME_IMPORT_ERROR}")
with patch.object(CodingA2ARuntime, "_build_agent", return_value=MagicMock()):
return CodingA2ARuntime(
LiteLLMConfig(api_key="test-key", model="gpt-4o-mini"),
AgentMetadata(),
)
def test_detect_git_provider(self):
self.assertEqual(detect_git_provider("https://github.com/org/repo.git"), GitProvider.github)
self.assertEqual(detect_git_provider("https://gitlab.com/org/repo.git"), GitProvider.gitlab)
self.assertEqual(detect_git_provider("http://gitee.ath.cx:3000/org/repo.git"), GitProvider.gitea)
def test_build_authenticated_repo_url(self):
url = build_authenticated_repo_url(
"https://github.com/org/repo.git",
username="alice",
token="top secret",
)
self.assertTrue(url.startswith("https://alice:top%20secret@github.com/"))
def test_model_prefix(self):
self.assertEqual(ensure_model_prefix("taiji/gpt-4o-mini"), "openai:taiji/gpt-4o-mini")
self.assertEqual(ensure_model_prefix("openai:gpt-4o-mini"), "openai:gpt-4o-mini")
def test_safe_workspace_path_rejects_escape(self):
with self.assertRaises(ValueError):
safe_workspace_path("/tmp/workspace", "../etc/passwd")
def test_request_config_parsing(self):
cfg = CodingRequestConfig.model_validate(
{
"workspace": {"root_dir": "/tmp/demo", "allowed_paths": ["src", "tests"]},
"resources": {
"git": {"repo_url": "https://github.com/org/repo.git", "token": "abc"},
},
}
)
self.assertEqual(cfg.workspace.root_dir, "/tmp/demo")
self.assertEqual(cfg.resources.git.provider, GitProvider.github)
def test_agent_metadata_uses_instruction_text_from_env(self):
with patch.dict(
"os.environ",
{
"AGENT_ROLE_NAME": "backend",
"AGENT_INSTRUCTION_TEXT": "# Role\nYou are backend engineer",
},
clear=False,
):
meta = AgentMetadata()
self.assertEqual(meta.role_name, "backend")
self.assertEqual(meta.instruction_source, "env_text")
self.assertIn("backend engineer", meta.effective_system_prompt)
def test_agent_metadata_uses_instruction_file_from_env(self):
with tempfile.TemporaryDirectory() as tmp_dir:
instruction_path = Path(tmp_dir) / "AGENTS.md"
instruction_path.write_text("# Role\nYou are reviewer", encoding="utf-8")
with patch.dict(
"os.environ",
{
"AGENT_INSTRUCTION_FILE": str(instruction_path),
},
clear=False,
):
meta = AgentMetadata()
self.assertTrue(meta.instruction_source.startswith("env_file:"))
self.assertIn("You are reviewer", meta.effective_system_prompt)
def test_resource_config_loads_optional_env_defaults(self):
with patch.dict(
"os.environ",
{
"GIT_REPO_URL": "https://github.com/acme/demo.git",
"GIT_TOKEN": "abc",
"MYSQL_HOST": "mysql.internal",
"MYSQL_USER": "demo",
"MYSQL_PASSWORD": "secret",
"MYSQL_DATABASE": "appdb",
"AZURE_BLOB_CONTAINER": "artifacts",
"AZURE_BLOB_CONNECTION_STRING": "UseDevelopmentStorage=true",
},
clear=False,
):
cfg = CodingRequestConfig()
self.assertEqual(cfg.resources.git.repo_url, "https://github.com/acme/demo.git")
self.assertEqual(cfg.resources.mysql.host, "mysql.internal")
self.assertEqual(cfg.resources.azure_blob.container_name, "artifacts")
self.assertEqual(
sorted(cfg.resources.enabled_resource_names),
["azure_blob", "git", "mysql"],
)
def test_request_resource_overrides_env_defaults(self):
with patch.dict(
"os.environ",
{
"AZURE_BLOB_CONTAINER": "env-container",
"AZURE_BLOB_CONNECTION_STRING": "env-conn",
},
clear=False,
):
cfg = CodingRequestConfig.model_validate(
{
"resources": {
"azure_blob": {
"container_name": "request-container",
"connection_string": "request-conn",
}
}
}
)
self.assertEqual(cfg.resources.azure_blob.container_name, "request-container")
self.assertEqual(cfg.resources.azure_blob.connection_string, "request-conn")
def test_prepare_workspace_creates_missing_root_without_git(self):
runtime = self._make_runtime()
with tempfile.TemporaryDirectory() as tmp_dir:
workspace_root = Path(tmp_dir) / "workspace"
cfg = CodingRequestConfig.model_validate({"workspace": {"root_dir": str(workspace_root)}})
deps = CodingRunContext(workspace=cfg.workspace, resources=cfg.resources)
runtime._prepare_workspace(cfg, deps)
self.assertTrue(workspace_root.exists())
self.assertTrue(workspace_root.is_dir())
def test_prepare_workspace_raises_structured_error_for_git_clone_failure(self):
runtime = self._make_runtime()
with tempfile.TemporaryDirectory() as tmp_dir:
workspace_root = Path(tmp_dir) / "repo"
cfg = CodingRequestConfig.model_validate(
{
"workspace": {"root_dir": str(workspace_root)},
"resources": {
"git": {
"repo_url": "https://github.com/acme/demo.git",
"default_branch": "main",
"token": "abc",
}
},
}
)
deps = CodingRunContext(workspace=cfg.workspace, resources=cfg.resources)
failed = subprocess.CompletedProcess(
args=["git", "clone"],
returncode=128,
stdout="",
stderr="fatal: repository not found",
)
with patch("coding_a2a_agent.agent.subprocess.run", return_value=failed):
with self.assertRaises(WorkspacePreparationError) as ctx:
runtime._prepare_workspace(cfg, deps)
self.assertEqual(ctx.exception.code, "git_prepare_failed")
self.assertEqual(ctx.exception.stage, "git_prepare_workspace")
self.assertIn("fatal: repository not found", ctx.exception.to_payload()["stderr"])
def test_message_send_returns_structured_jsonrpc_error_for_workspace_failures(self):
if RUNTIME_IMPORT_ERROR is not None:
self.skipTest(f"runtime dependencies unavailable: {RUNTIME_IMPORT_ERROR}")
fake_runtime = MagicMock()
fake_runtime.run_task = AsyncMock(
side_effect=WorkspacePreparationError(
"git workspace preparation failed",
code="git_prepare_failed",
stage="git_prepare_workspace",
data={"repo_url": "https://github.com/acme/demo.git"},
)
)
class DummyContextManager:
def __init__(self, *args, **kwargs):
self.tool_log = []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def add_tool(self, _tool: str):
return None
with patch("coding_a2a_agent.a2a_server.CodingA2ARuntime", return_value=fake_runtime), patch(
"coding_a2a_agent.a2a_server.AgentCallbackHandler", return_value=MagicMock()
), patch("coding_a2a_agent.a2a_server.CallbackContextManager", DummyContextManager):
server = CodingA2AServer(api_key="test-key", model="gpt-4o-mini")
request = A2ARequest(
id="req-1",
method="message/send",
params={
"message": {"role": "user", "parts": [{"kind": "text", "text": "hello"}]},
"configuration": {"workspace": {"root_dir": "/tmp/demo"}},
},
)
response = asyncio.run(server._handle_message_send(request))
payload = json.loads(response.body.decode("utf-8"))
self.assertEqual(payload["error"]["code"], -32010)
self.assertEqual(payload["error"]["data"]["code"], "git_prepare_failed")
self.assertEqual(payload["error"]["data"]["stage"], "git_prepare_workspace")
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -49,6 +49,7 @@ declare -A AGENTS=(
["azure-blob-agent-a2a"]="agents/azure_blob_agent_a2a/azure_blob_agent_a2a.Dockerfile"
["azure-blob-agent-mcp"]="agents/azure_blob_agent_mcp/azure_blob_agent_mcp.Dockerfile"
["a2a-litellm-agent"]="agents/a2a_litellm_agent/a2a_litellm_agent.Dockerfile"
["coding-a2a-agent"]="agents/coding_a2a_agent/coding_a2a_agent.Dockerfile"
["mysql-agent"]="agents/mysql_agent/mysql_agent.Dockerfile"
["postgresql-agent"]="agents/postgresql_agent/postgresql_agent.Dockerfile"
["jina-search-agent"]="agents/jina_search_agent/jina_search_agent.Dockerfile"
@@ -129,4 +130,3 @@ echo "已推送的镜像:"
for name in "${!AGENTS[@]}"; do
echo " - ${ACR_NAME}/ai-agents/${name}:${TAG}"
done
+5
View File
@@ -0,0 +1,5 @@
"""Primary agent API module for Heicode sub-mode runtime."""
from .router import router
__all__ = ["router"]
+37
View File
@@ -0,0 +1,37 @@
"""Primary router for Heicode sub-mode runtime APIs."""
import logging
from fastapi import APIRouter, Request
from api.agnet.auth import extract_headers
from api.agnet.callbacks import router as callbacks_router, user_router as callback_user_router
from api.agnet.deployments import router as deployments_router
from api.agnet.models import HealthCheckResponse
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent", tags=["agent"])
sub_agile_router = APIRouter(prefix="/sub-agile", tags=["agent-sub-agile"])
sub_agile_router.include_router(deployments_router)
router.include_router(sub_agile_router)
router.include_router(callbacks_router)
router.include_router(callback_user_router)
@router.get("/health", response_model=HealthCheckResponse)
async def health_check(request: Request):
"""Health check endpoint for the primary sub-mode runtime surface."""
headers = extract_headers(request)
logger.info("Agent health check - correlation_id=%s", headers["correlation_id"])
return {
"success": True,
"data": {
"status": "healthy",
"service": "agent-manager-sub-mode-runtime",
"version": "1.0.0",
"phase": "sub-mode-runtime",
},
}
+1 -1
View File
@@ -1 +1 @@
"""Agnet API module for Heicode integration."""
"""Legacy agnet compatibility module for Heicode sub-mode runtime APIs."""
+173 -15
View File
@@ -1,8 +1,9 @@
"""Runtime callback endpoints for Heicode sub-mode events."""
"""Runtime callback and observability endpoints for Heicode sub-mode events."""
from datetime import datetime, timezone
import hashlib
import hmac
import json
import mimetypes
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -22,11 +23,14 @@ from api.agnet.auth import verify_service_token
from api.agnet.validators import validate_no_sensitive_fields
from api.agnet.vault_client import vault_client
from api.swarm.artifact_store import load_azblob_artifact, load_runtime_artifact, runtime_uri_parts
from api.swarm.artifact_store import load_runtime_project_artifact, load_runtime_project_file
from api.status_projection import RuntimeDisplayStatus
from config.error_codes import ErrorCode
from config.settings import settings
router = APIRouter(prefix="/callbacks", tags=["agnet-callbacks"])
user_router = APIRouter(prefix="/user/deployments", tags=["agnet-user-observability"])
router = APIRouter(prefix="/callbacks", tags=["agent-callbacks"])
compat_router = APIRouter(prefix="/callbacks", tags=["agnet-callbacks"])
user_router = APIRouter(prefix="/user/deployments", tags=["agent-user-observability"])
PHASES = {
@@ -211,6 +215,10 @@ def _update_projection_state(
status_value = payload.get("status") or payload.get("to_status")
if status_value in DBDeploymentStatus._value2member_map_:
deployment.status = DBDeploymentStatus(status_value)
elif event_type == "approval.requested":
deployment.status = DBDeploymentStatus.RUNNING
if not deployment.phase:
deployment.phase = RuntimeDisplayStatus.WAITING_APPROVAL.value
elif event_type in STATUS_BY_EVENT and STATUS_BY_EVENT[event_type] is not None:
deployment.status = STATUS_BY_EVENT[event_type]
@@ -225,6 +233,8 @@ def _update_projection_state(
agent.status = DBDeploymentStatus.STOPPED
elif event_type in {"agent.crashed", "sk_tool.failed"}:
agent.status = DBDeploymentStatus.FAILED
elif event_type == "approval.requested":
agent.status = DBDeploymentStatus.RUNNING
deployment.updated_at = datetime.utcnow()
@@ -248,9 +258,10 @@ def _audit_approval_request(db: Session, deployment: Deployment, event_id: str,
)
@router.post("/swarm-events")
async def receive_swarm_event(request: Request, db: Session = Depends(get_db)):
"""Receive Runtime events using the HEICODE_API_INTEGRATION v2.1 contract."""
@router.post("/runtime-events")
@compat_router.post("/swarm-events")
async def receive_runtime_event(request: Request, db: Session = Depends(get_db)):
"""Receive runtime events using the HEICODE_API_INTEGRATION v2.1 contract."""
raw_body = await request.body()
try:
body = json.loads(raw_body.decode("utf-8") or "{}")
@@ -309,9 +320,10 @@ async def receive_swarm_event(request: Request, db: Session = Depends(get_db)):
return {"success": True, "event_id": event_id, "deduplicated": False}
@router.get("/swarm-events/schema")
async def get_swarm_event_schema():
"""Expose callback contract metadata for Agent Manager联调."""
@router.get("/runtime-events/schema")
@compat_router.get("/swarm-events/schema")
async def get_runtime_event_schema():
"""Expose callback contract metadata for sub-mode runtime integration."""
event_types = {
"deployment.status_changed": {
"category": "status",
@@ -329,6 +341,22 @@ async def get_swarm_event_schema():
"category": "artifact",
"required_payload_fields": ["artifact_id", "artifact_type", "title"],
},
"artifact.local_edit_applied": {
"category": "artifact",
"required_payload_fields": ["artifact_id", "project_revision", "manifest_uri", "archive_uri"],
},
"artifact.local_edit_reviewed": {
"category": "artifact",
"required_payload_fields": ["artifact_id", "project_revision"],
},
"artifact.local_edit_rejected": {
"category": "artifact",
"required_payload_fields": ["artifact_id", "project_revision"],
},
"artifact.local_edit_conflict": {
"category": "artifact",
"required_payload_fields": ["artifact_id", "project_revision", "base_project_revision"],
},
"approval.requested": {
"category": "approval",
"required_payload_fields": ["approval_id", "operation", "risk_level", "reason"],
@@ -376,7 +404,8 @@ async def get_swarm_event_schema():
}
return {
"success": True,
"endpoint": "/api/agnet/callbacks/swarm-events",
"endpoint": "/api/agent/callbacks/runtime-events",
"compatibility_endpoints": ["/api/agnet/callbacks/swarm-events"],
"headers": {
"X-Agnet-Event-Id": "required for idempotency",
"X-Agnet-Timestamp": "required for HMAC, Unix milliseconds",
@@ -386,7 +415,7 @@ async def get_swarm_event_schema():
"body_required_fields": ["event_id", "event_type", "deployment_id", "occurred_at", "payload"],
"event_types": event_types,
"stages": ["planning", "design", "development", "testing", "fixing", "deployment", "review", "done", "failed"],
"artifact_types": ["code_patch", "document", "test_report", "deployment_manifest", "log_bundle", "other"],
"artifact_types": ["project_folder", "code_patch", "document", "test_report", "deployment_manifest", "log_bundle", "other"],
}
@@ -407,7 +436,7 @@ async def list_deployment_artifacts(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return artifacts projected from v2.1 artifact.created callbacks."""
"""Return artifacts projected from runtime artifact.created callbacks."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
@@ -432,6 +461,7 @@ async def list_deployment_artifacts(
"stage": artifact.get("stage"),
"checkpoint": artifact.get("checkpoint"),
"metadata": artifact.get("metadata") or {},
"source_agent_role": (artifact.get("metadata") or {}).get("source_agent_role") or (artifact.get("metadata") or {}).get("agent_role"),
"created_at": event.occurred_at,
})
@@ -445,7 +475,7 @@ async def get_deployment_artifact_content(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return full content for Runtime-local artifacts referenced by artifact.created events."""
"""Return full content for runtime-local artifacts referenced by artifact.created events."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
@@ -467,6 +497,13 @@ async def get_deployment_artifact_content(
parts = runtime_uri_parts(artifact_payload.get("uri"))
if parts:
project_artifact = load_runtime_project_artifact(*parts)
if project_artifact:
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
stored = load_runtime_artifact(*parts)
if stored:
return FileResponse(
@@ -502,13 +539,134 @@ async def get_deployment_artifact_content(
raise HTTPException(status_code=404, detail="Artifact content not found")
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/manifest")
async def get_deployment_artifact_manifest(
deployment_id: str,
artifact_id: str,
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return manifest JSON for a project-folder artifact."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
.order_by(Event.occurred_at.asc())
.all()
)
artifact_payload = None
for event in events:
payload = event.payload or {}
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
artifact_payload = artifact
break
if not artifact_payload:
raise HTTPException(status_code=404, detail="Artifact not found")
parts = runtime_uri_parts(artifact_payload.get("uri"))
if parts:
project_artifact = load_runtime_project_artifact(*parts)
if project_artifact:
return FileResponse(
path=project_artifact.manifest_path,
media_type="application/json",
filename=project_artifact.manifest_path.name,
)
raise HTTPException(status_code=404, detail="Project artifact manifest not found")
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/archive.zip")
async def get_deployment_artifact_archive(
deployment_id: str,
artifact_id: str,
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return zip archive for a project-folder artifact."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
.order_by(Event.occurred_at.asc())
.all()
)
artifact_payload = None
for event in events:
payload = event.payload or {}
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
artifact_payload = artifact
break
if not artifact_payload:
raise HTTPException(status_code=404, detail="Artifact not found")
parts = runtime_uri_parts(artifact_payload.get("uri"))
if parts:
project_artifact = load_runtime_project_artifact(*parts)
if project_artifact:
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
raise HTTPException(status_code=404, detail="Project artifact archive not found")
@user_router.get("/{deployment_id}/artifacts/{artifact_id}/files/{file_path:path}")
async def get_deployment_artifact_file(
deployment_id: str,
artifact_id: str,
file_path: str,
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return a single file from a project-folder artifact."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
.filter(Event.deployment_id == deployment_id, Event.event_type == "artifact.created")
.order_by(Event.occurred_at.asc())
.all()
)
artifact_payload = None
for event in events:
payload = event.payload or {}
artifact = payload.get("artifact") if isinstance(payload.get("artifact"), dict) else payload
if (artifact.get("artifact_id") or event.event_id) == artifact_id:
artifact_payload = artifact
break
if not artifact_payload:
raise HTTPException(status_code=404, detail="Artifact not found")
parts = runtime_uri_parts(artifact_payload.get("uri"))
if parts:
resolved_file = load_runtime_project_file(parts[0], parts[1], file_path)
if resolved_file:
return FileResponse(
path=resolved_file,
media_type=mimetypes.guess_type(str(resolved_file))[0] or "text/plain",
filename=resolved_file.name,
)
raise HTTPException(status_code=404, detail="Project artifact file not found")
@user_router.get("/{deployment_id}/timeline")
async def list_deployment_timeline(
deployment_id: str,
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return a merged timeline from callback events."""
"""Return a merged timeline from runtime callback events."""
_ensure_deployment(db, deployment_id)
timeline_event_types = {
"timeline.updated",
@@ -560,7 +718,7 @@ async def list_deployment_sk_snapshots(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Return SK snapshots projected from Runtime callback payloads."""
"""Return SK snapshots projected from runtime callback payloads."""
_ensure_deployment(db, deployment_id)
events = (
db.query(Event)
+131 -24
View File
@@ -1,4 +1,4 @@
"""Deployment endpoints for Heicode integration."""
"""Deployment endpoints for Heicode sub-mode runtime integration."""
from fastapi import APIRouter, Depends, Request, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Optional
@@ -14,7 +14,7 @@ from api.agnet.models import (
ListDeploymentsResponse, GetDeploymentResponse, StopDeploymentRequest, StopDeploymentResponse,
DeploymentSummary, BudgetSummary, PaginationInfo,
GetLogsResponse, LogEntry, GetEventsResponse, EventEntry, GetMetricsResponse,
AgentMetrics, ResourceMetrics
AgentMetrics, ResourceMetrics, ArtifactEditRequest, ArtifactEditResponse
)
from api.agnet.auth import verify_service_token, extract_headers
from api.agnet.validators import validate_no_sensitive_fields, validate_vault_references
@@ -22,6 +22,8 @@ from api.agnet.idempotency import idempotency_cache
from api.agnet.k8s_manager import k8s_manager
from api.agnet.vault_client import vault_client
from api.swarm.callback_client import CallbackDeliveryClient
from api.swarm.router import apply_runtime_artifact_edit, _phases_for_swarm, _emit_artifact_edit_event
from api.status_projection import RuntimeDisplayStatus, project_deployment_status, project_runtime_run_status
from config.error_codes import ErrorCode
from config.settings import settings
import logging
@@ -33,6 +35,45 @@ logger = logging.getLogger(__name__)
router = APIRouter()
def _project_deployment_status_for_record(db: Session, deployment: Deployment) -> str:
"""Project persisted deployment state into a Manager-facing runtime status."""
events = (
db.query(Event.event_type, Event.payload)
.filter(Event.deployment_id == deployment.deployment_id)
.order_by(Event.occurred_at.asc())
.all()
)
normalized_events = [{"event_type": event_type, "payload": payload or {}} for event_type, payload in events]
return project_deployment_status(deployment.status, phase=deployment.phase, events=normalized_events)
def _project_agent_instance_status(deployment_status: str, agent_status: str) -> str:
"""Keep agent rows aligned with the projected deployment status contract."""
if deployment_status == RuntimeDisplayStatus.ACCEPTED.value and agent_status == DBDeploymentStatus.PENDING.value:
return RuntimeDisplayStatus.ACCEPTED.value
return agent_status
def _runtime_mode_from_orchestration_plan(orchestration_plan: str | dict | None) -> str:
"""Best-effort projection of runtime mode for list/detail surfaces."""
plan = orchestration_plan
if isinstance(orchestration_plan, str):
try:
plan = json.loads(orchestration_plan)
except Exception:
plan = {}
if not isinstance(plan, dict):
plan = {}
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), dict) else {}
runtime_mode = metadata.get("runtime_mode") or plan.get("runtime_mode")
if runtime_mode == "swarm":
return "swarm"
sub_mode = plan.get("sub_mode")
if sub_mode in {"agile", "waterfall"}:
return "sub_agile"
return "sub_agile"
def generate_deployment_id() -> str:
"""Generate unique deployment ID."""
return f"dep_{uuid.uuid4().hex[:12]}"
@@ -200,11 +241,11 @@ async def emit_sub_mode_lifecycle_callbacks(
)
def get_agnet_namespace(user_id: str, binding_scope: str) -> str:
"""Generate namespace for Heicode deployments."""
def get_agent_namespace(user_id: str, binding_scope: str) -> str:
"""Generate namespace for Heicode agent deployments."""
combined = f"{user_id}:{binding_scope}"
hash_suffix = hashlib.sha256(combined.encode()).hexdigest()[:6]
namespace = f"agnet-{user_id}-{hash_suffix}"[:63]
namespace = f"agent-{user_id}-{hash_suffix}"[:63]
return namespace.lower().replace("_", "-")
@@ -268,16 +309,23 @@ def build_plan_summary(request: CreateDeploymentRequest) -> dict:
def build_deployment_response_from_swarm(db: Session, swarm: Swarm) -> GetDeploymentResponse:
"""Expose /api/swarms-created runs through deployment detail compatibility."""
"""Expose compatibility runtime runs through the deployment-detail shape."""
context = swarm.project_context or {}
billing_context = context.get("billing_context") or {}
budget = context.get("budget") or {}
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm.swarm_id).all()
display_status = project_runtime_run_status(swarm.status)
phases = [phase.model_dump() for phase in _phases_for_swarm(db, swarm)]
agent_phase_map = {}
for phase in phases:
for phase_agent in phase.get("agents", []):
agent_phase_map[phase_agent.get("agent_id")] = phase_agent
return GetDeploymentResponse(
deployment_id=swarm.swarm_id,
user_id=swarm.owner_id,
binding_scope=context.get("binding_scope") or context.get("intent_id") or swarm.swarm_id,
status=swarm.status.value,
status=display_status,
mode="sub_agile",
phase=swarm.phase,
orchestration_plan=json.dumps(
{
@@ -304,11 +352,17 @@ def build_deployment_response_from_swarm(db: Session, swarm: Swarm) -> GetDeploy
AgentInstanceResponse(
agent_instance_id=agent.agent_id,
role=agent.role,
status=agent.status.value,
status=_project_agent_instance_status(display_status, agent.status.value),
phase=swarm.phase,
tokens=(agent_phase_map.get(agent.agent_id) or {}).get("tokens", 0),
tools=(agent_phase_map.get(agent.agent_id) or {}).get("tools", 0),
elapsed_seconds=(agent_phase_map.get(agent.agent_id) or {}).get("elapsed_seconds", 0),
artifact_ids=(agent_phase_map.get(agent.agent_id) or {}).get("artifact_ids", []),
current_action=(agent_phase_map.get(agent.agent_id) or {}).get("current_action"),
)
for agent in agents
],
phases=phases,
resource_grants=context.get("resource_grants") or [],
created_at=swarm.created_at,
updated_at=swarm.updated_at,
@@ -351,7 +405,7 @@ async def create_deployment(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Create a new deployment."""
"""Create a new sub-agile runtime deployment."""
headers = extract_headers(http_request)
correlation_id = headers.get("correlation_id")
user_id = headers.get("user_id")
@@ -378,7 +432,7 @@ async def create_deployment(
user_id = user_id or plan_user_context.get("user_id") or "default"
binding_scope = binding_scope or plan_user_context.get("binding_scope") or f"task-{plan_summary.get('intent_id') or deployment_id}"
correlation_id = correlation_id or request.metadata.get("correlation_id")
namespace = get_agnet_namespace(user_id, binding_scope)
namespace = get_agent_namespace(user_id, binding_scope)
# Create deployment record
deployment = Deployment(
@@ -552,12 +606,13 @@ async def create_deployment(
response = CreateDeploymentResponse(
deployment_id=deployment_id,
swarm_id=deployment_id,
status="pending",
status=RuntimeDisplayStatus.ACCEPTED.value,
mode="sub_agile",
agent_instances=[
AgentInstanceResponse(
agent_instance_id=inst.agent_instance_id,
role=inst.role,
status="pending",
status=RuntimeDisplayStatus.ACCEPTED.value,
phase=None
)
for inst in agent_instances
@@ -567,7 +622,8 @@ async def create_deployment(
data={
"deployment_id": deployment_id,
"swarm_id": deployment_id,
"status": "pending",
"status": RuntimeDisplayStatus.ACCEPTED.value,
"mode": "sub_agile",
"estimated_ready_at": (deployment.created_at + timedelta(minutes=2)).isoformat(),
},
)
@@ -637,7 +693,7 @@ async def list_deployments(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""List deployments with filtering and pagination."""
"""List sub-mode runtime deployments with filtering and pagination."""
query = db.query(Deployment)
# Apply filters
@@ -666,10 +722,12 @@ async def list_deployments(
instance_count = db.query(AgentInstance).filter(
AgentInstance.deployment_id == dep.deployment_id
).count()
display_status = _project_deployment_status_for_record(db, dep)
deployment_summaries.append(DeploymentSummary(
deployment_id=dep.deployment_id,
status=dep.status.value,
status=display_status,
mode=_runtime_mode_from_orchestration_plan(dep.orchestration_plan),
risk_level=dep.risk_level.value,
budget=BudgetSummary(
max_usd=dep.budget_max_usd or 0.0,
@@ -695,7 +753,7 @@ async def get_deployment(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Get deployment details."""
"""Get sub-mode runtime deployment details."""
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
).first()
@@ -719,12 +777,14 @@ async def get_deployment(
instances = db.query(AgentInstance).filter(
AgentInstance.deployment_id == deployment_id
).all()
display_status = _project_deployment_status_for_record(db, deployment)
return GetDeploymentResponse(
deployment_id=deployment.deployment_id,
user_id=deployment.user_id,
binding_scope=deployment.binding_scope,
status=deployment.status.value,
status=display_status,
mode=_runtime_mode_from_orchestration_plan(deployment.orchestration_plan),
phase=deployment.phase,
orchestration_plan=deployment.orchestration_plan,
risk_level=deployment.risk_level.value,
@@ -742,11 +802,12 @@ async def get_deployment(
AgentInstanceResponse(
agent_instance_id=inst.agent_instance_id,
role=inst.role,
status=inst.status.value,
status=_project_agent_instance_status(display_status, inst.status.value),
phase=inst.phase
)
for inst in instances
],
phases=[],
resource_grants=deployment.resource_grants or [],
created_at=deployment.created_at,
updated_at=deployment.updated_at
@@ -761,7 +822,7 @@ async def stop_deployment(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Stop a deployment."""
"""Stop a sub-mode runtime deployment."""
headers = extract_headers(http_request)
correlation_id = headers.get("correlation_id")
user_id = headers.get("user_id")
@@ -784,7 +845,7 @@ async def stop_deployment(
db.commit()
return StopDeploymentResponse(
deployment_id=deployment_id,
status="stopped",
status=RuntimeDisplayStatus.STOPPED.value,
stopped_at=stopped_at,
)
raise HTTPException(
@@ -896,6 +957,52 @@ async def stop_deployment(
)
@router.post("/deployments/{deployment_id}/artifact-edits", response_model=ArtifactEditResponse)
async def receive_deployment_artifact_edit(
deployment_id: str,
request: ArtifactEditRequest,
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Accept an accepted project revision for a runtime-backed deployment."""
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
).first()
if deployment:
raise HTTPException(
status_code=422,
detail={
"success": False,
"error": {
"code": ErrorCode.INVALID_REQUEST,
"message": "ARTIFACT_EDIT_UNSUPPORTED_FOR_LAYOUT",
},
},
)
swarm = db.query(Swarm).filter(Swarm.swarm_id == deployment_id).first()
if not swarm:
raise HTTPException(
status_code=404,
detail={
"success": False,
"error": {
"code": ErrorCode.DEPLOYMENT_NOT_FOUND,
"message": f"Deployment {deployment_id} not found",
},
},
)
try:
response = apply_runtime_artifact_edit(db, deployment_id, request)
except HTTPException as exc:
if exc.status_code == 409:
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_conflict")
raise
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_applied")
return response
@router.post("/deployments/{deployment_id}/approvals/{approval_id}")
async def receive_deployment_approval_decision(
deployment_id: str,
@@ -905,7 +1012,7 @@ async def receive_deployment_approval_decision(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token),
):
"""Accept Manager approval decisions for ordinary sub-mode Runtime actions."""
"""Accept Manager approval decisions for ordinary sub-mode runtime actions."""
headers = extract_headers(http_request)
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
@@ -979,7 +1086,7 @@ async def get_deployment_logs(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Get logs for a deployment or specific agent instance."""
"""Get logs for a sub-mode runtime deployment or agent instance."""
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
).first()
@@ -1062,7 +1169,7 @@ async def get_deployment_events(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Get events for a deployment."""
"""Get events for a sub-mode runtime deployment."""
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
).first()
@@ -1111,7 +1218,7 @@ async def get_deployment_metrics(
db: Session = Depends(get_db),
token: str = Depends(verify_service_token)
):
"""Get resource metrics for a deployment."""
"""Get resource metrics for a sub-mode runtime deployment."""
deployment = db.query(Deployment).filter(
Deployment.deployment_id == deployment_id
).first()
+62 -4
View File
@@ -1,4 +1,4 @@
"""Pydantic models for Heicode integration API."""
"""Pydantic models for Heicode sub-mode runtime APIs."""
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import List, Optional, Dict, Any, Union
from datetime import datetime
@@ -19,13 +19,24 @@ class RiskLevel(str, Enum):
class DeploymentStatus(str, Enum):
"""Deployment status."""
"""Legacy deployment persistence status."""
PENDING = "pending"
RUNNING = "running"
STOPPED = "stopped"
FAILED = "failed"
class RuntimeDisplayStatus(str, Enum):
"""Projected Manager-facing runtime status."""
ACCEPTED = "accepted"
RUNNING = "running"
WAITING_APPROVAL = "waiting_approval"
COMPLETED = "completed"
FAILED = "failed"
STOPPED = "stopped"
DEFAULT_CALLBACK_EVENTS = [
"deployment.status_changed",
"phase.changed",
@@ -164,7 +175,7 @@ class ResourceGrant(BaseModel):
class CallbackConfig(BaseModel):
"""Callback configuration for Agnet -> Heicode event delivery."""
"""Callback configuration for Agent Runtime -> Heicode event delivery."""
url: str = Field(..., description="HTTPS callback endpoint")
signing_secret_ref: str = Field(..., description="Vault path to callback signing secret")
subscribed_events: List[str] = Field(
@@ -198,7 +209,7 @@ class CreateDeploymentRequest(BaseModel):
budget: Optional[BudgetConfig] = Field(None, description="Budget configuration")
billing_context: Optional[BillingContext] = Field(None, description="Billing and model gateway config")
resource_grants: List[ResourceGrant] = Field(default_factory=list, description="Resource grants")
callback: Optional[CallbackConfig] = Field(None, description="Agnet -> Heicode callback configuration")
callback: Optional[CallbackConfig] = Field(None, description="Agent Runtime -> Heicode callback configuration")
agile_context: Optional[Dict[str, Any]] = Field(None, description="Heicode sub-mode agile context")
sub_mode: Optional[str] = Field(None, description="Heicode sub mode: agile or waterfall")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
@@ -287,6 +298,11 @@ class AgentInstanceResponse(BaseModel):
role: str
status: str
phase: Optional[str] = None
tokens: int = 0
tools: int = 0
elapsed_seconds: int = 0
artifact_ids: List[str] = Field(default_factory=list)
current_action: Optional[str] = None
class CreateDeploymentResponse(BaseModel):
@@ -295,6 +311,7 @@ class CreateDeploymentResponse(BaseModel):
deployment_id: str
swarm_id: Optional[str] = None
status: str
mode: Optional[str] = None
agent_instances: List[AgentInstanceResponse]
created_at: datetime
estimated_ready_at: Optional[datetime] = None
@@ -312,6 +329,7 @@ class DeploymentSummary(BaseModel):
"""Deployment summary for list response."""
deployment_id: str
status: str
mode: Optional[str] = None
risk_level: str
budget: BudgetSummary
created_at: datetime
@@ -355,12 +373,14 @@ class GetDeploymentResponse(BaseModel):
user_id: str
binding_scope: str
status: str
mode: Optional[str] = None
phase: Optional[str]
orchestration_plan: str
risk_level: str
budget: BudgetSummary
billing_context: Dict[str, Any]
agent_instances: List[AgentInstanceResponse]
phases: List[Dict[str, Any]] = Field(default_factory=list)
resource_grants: List[Dict[str, Any]]
created_at: datetime
updated_at: datetime
@@ -436,3 +456,41 @@ class GetMetricsResponse(BaseModel):
timestamp: datetime
agent_metrics: List[AgentMetrics]
total_resources: ResourceMetrics
class SubAgileDeploymentCreateRequest(CreateDeploymentRequest):
"""Primary request model for /api/agent/sub-agile/deployments."""
class SubAgileDeploymentCreateResponse(CreateDeploymentResponse):
"""Primary response model for /api/agent/sub-agile/deployments."""
class SubAgileDeploymentDetailResponse(GetDeploymentResponse):
"""Primary response model for /api/agent/sub-agile/deployments/{id}."""
class SubAgileDeploymentStopResponse(StopDeploymentResponse):
"""Primary response model for /api/agent/sub-agile/deployments/{id}/stop."""
class ArtifactEditRequest(BaseModel):
"""Manager-forwarded accepted revision metadata for a project artifact."""
artifact_id: str
project_revision: int
base_project_revision: Optional[int] = None
base_content_hash: Optional[str] = None
manifest_uri: str
archive_uri: str
source: str
class ArtifactEditResponse(BaseModel):
"""Runtime acknowledgement for artifact revision updates."""
success: bool = True
deployment_id: str
artifact_id: str
project_revision: int
status: str
+7 -7
View File
@@ -1,19 +1,19 @@
"""Main router for Heicode integration API."""
"""Legacy compatibility router for Heicode sub-mode runtime APIs."""
from fastapi import APIRouter, Request
from api.agnet.auth import extract_headers
from api.agnet.models import HealthCheckResponse
from api.agnet.deployments import router as deployments_router
from api.agnet.callbacks import router as callbacks_router, user_router as callback_user_router
from api.agnet.callbacks import compat_router as callbacks_router, user_router as callback_user_router
import logging
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/agnet",
tags=["agnet"],
tags=["agent-compatibility"],
)
# Include deployment endpoints
# Legacy compatibility endpoints reuse the shared sub-mode runtime handlers.
router.include_router(deployments_router)
router.include_router(callbacks_router)
router.include_router(callback_user_router)
@@ -21,7 +21,7 @@ router.include_router(callback_user_router)
@router.get("/health", response_model=HealthCheckResponse)
async def health_check(request: Request):
"""Health check endpoint for Heicode integration.
"""Health check endpoint for the legacy compatibility surface.
Returns service status and version information.
"""
@@ -32,8 +32,8 @@ async def health_check(request: Request):
"success": True,
"data": {
"status": "healthy",
"service": "agent-manager-agnet",
"service": "agent-manager-sub-mode-runtime",
"version": "1.0.0",
"phase": "2-deployments"
"phase": "sub-mode-runtime-compatibility"
}
}
+51 -2
View File
@@ -1,5 +1,6 @@
"""Vault client for secrets management."""
import logging
import os
import re
from typing import Optional, Dict, Any
from urllib.parse import urlparse
@@ -111,13 +112,16 @@ class VaultClient:
if not self.validate_azkv_reference(ref):
logger.error(f"Invalid Azure Key Vault reference: {ref}")
return None
env_name = self._env_name_from_secret_ref(ref)
env_value = os.getenv(env_name)
if env_value:
return env_value
if not self.enabled:
logger.warning(f"Vault not configured, returning mock secret for {ref}")
parsed_azkv = urlparse(ref)
secret_name = parsed_azkv.path.rstrip("/").split("/")[-1]
return f"mock-secret-azkv-{secret_name}"
logger.warning("Azure Key Vault fetching is not configured in this client; returning None")
return None
return await self._fetch_azkv_secret(ref)
parsed = self.parse_vault_reference(ref)
if not parsed:
@@ -202,6 +206,51 @@ class VaultClient:
# K8s secrets need base64 encoding, but the K8s client handles that
return secrets
def _env_name_from_secret_ref(self, ref: str) -> str:
"""Map an azkv secret ref to its conventional env var name."""
parsed = urlparse(ref)
secret_name = parsed.path.rstrip("/").split("/")[-1]
return secret_name.upper().replace("-", "_")
async def _fetch_azkv_secret(self, ref: str) -> Optional[str]:
"""Fetch azkv://<vault>/secrets/<name> using Azure app credentials."""
parsed = urlparse(ref)
parts = [part for part in parsed.path.split("/") if part]
if parsed.scheme != "azkv" or not parsed.netloc or len(parts) < 2 or parts[0] != "secrets":
logger.error(f"Invalid Azure Key Vault reference: {ref}")
return None
tenant_id = os.getenv("AZURE_TENANT_ID")
client_id = os.getenv("AZURE_CLIENT_ID")
client_secret = os.getenv("AZURE_CLIENT_SECRET")
if not (tenant_id and client_id and client_secret):
logger.warning("Azure credentials unavailable for azkv secret resolution")
return None
token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
token_data = {
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "https://vault.azure.net/.default",
}
secret_url = f"https://{parsed.netloc}/secrets/{parts[1]}?api-version=7.4"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
token_response = await client.post(token_url, data=token_data)
token_response.raise_for_status()
access_token = token_response.json()["access_token"]
secret_response = await client.get(
secret_url,
headers={"Authorization": f"Bearer {access_token}"},
)
secret_response.raise_for_status()
return secret_response.json().get("value")
except Exception as e:
logger.error(f"Failed to fetch Azure Key Vault secret: {e}")
return None
# Global instance
vault_client = VaultClient()
+105
View File
@@ -0,0 +1,105 @@
"""Shared status projection helpers for Heicode sub-mode runtime APIs."""
from __future__ import annotations
from enum import Enum
from typing import Iterable, Mapping, Any
class RuntimeDisplayStatus(str, Enum):
"""User-facing runtime status projected for Manager-facing APIs."""
ACCEPTED = "accepted"
RUNNING = "running"
WAITING_APPROVAL = "waiting_approval"
COMPLETED = "completed"
FAILED = "failed"
STOPPED = "stopped"
_COMPLETED_PHASES = {"done", "deliver", "delivered", "deployment", "completed"}
_APPROVAL_EVENT_TYPES = {"approval.requested"}
_APPROVAL_RESOLUTION_EVENT_TYPES = {"approval.decision"}
_COMPLETED_EVENT_TYPES = {"task.completed"}
_FAILED_EVENT_TYPES = {"task.failed", "task.blocked", "deployment.failed", "agent.crashed"}
_STOPPED_EVENT_TYPES = {"deployment.stopped"}
_RUNNING_EVENT_TYPES = {"deployment.started", "agent.started"}
def _normalize_status(value: Any) -> str | None:
"""Normalize Enum/string statuses to lowercase strings."""
if value is None:
return None
if isinstance(value, Enum):
value = value.value
return str(value).strip().lower() or None
def _event_payload_status(event: Mapping[str, Any]) -> str | None:
"""Extract a comparable status from an event payload."""
payload = event.get("payload")
if not isinstance(payload, Mapping):
return None
return _normalize_status(payload.get("status") or payload.get("to_status"))
def project_deployment_status(
db_status: str | Enum | None,
*,
phase: str | None = None,
events: Iterable[Mapping[str, Any]] | None = None,
) -> str:
"""Project deployment DB state plus callback facts into a Manager-facing status."""
normalized_status = _normalize_status(db_status)
normalized_phase = _normalize_status(phase)
event_list = list(events or [])
last_approval_requested_idx = -1
last_approval_resolved_idx = -1
for idx, event in enumerate(event_list):
event_type = str(event.get("event_type") or "").strip()
payload_status = _event_payload_status(event)
if event_type in _APPROVAL_EVENT_TYPES:
last_approval_requested_idx = idx
elif event_type in _APPROVAL_RESOLUTION_EVENT_TYPES:
last_approval_resolved_idx = idx
if payload_status == RuntimeDisplayStatus.STOPPED.value or event_type in _STOPPED_EVENT_TYPES:
return RuntimeDisplayStatus.STOPPED.value
if payload_status == RuntimeDisplayStatus.FAILED.value or event_type in _FAILED_EVENT_TYPES:
return RuntimeDisplayStatus.FAILED.value
if payload_status == RuntimeDisplayStatus.COMPLETED.value or event_type in _COMPLETED_EVENT_TYPES:
return RuntimeDisplayStatus.COMPLETED.value
if payload_status == RuntimeDisplayStatus.RUNNING.value or event_type in _RUNNING_EVENT_TYPES:
normalized_status = RuntimeDisplayStatus.RUNNING.value
if last_approval_requested_idx > last_approval_resolved_idx:
return RuntimeDisplayStatus.WAITING_APPROVAL.value
if normalized_status == "stopped":
return RuntimeDisplayStatus.STOPPED.value
if normalized_status == "failed":
return RuntimeDisplayStatus.FAILED.value
if normalized_status == "running":
if normalized_phase in _COMPLETED_PHASES:
return RuntimeDisplayStatus.COMPLETED.value
return RuntimeDisplayStatus.RUNNING.value
if normalized_phase in _COMPLETED_PHASES:
return RuntimeDisplayStatus.COMPLETED.value
return RuntimeDisplayStatus.ACCEPTED.value
def project_runtime_run_status(runtime_status: str | Enum | None) -> str:
"""Project runtime-run statuses to the shared Manager-facing status set."""
normalized_status = _normalize_status(runtime_status)
if normalized_status == "completed":
return RuntimeDisplayStatus.COMPLETED.value
if normalized_status == "failed":
return RuntimeDisplayStatus.FAILED.value
if normalized_status == "stopped":
return RuntimeDisplayStatus.STOPPED.value
if normalized_status == "running":
return RuntimeDisplayStatus.RUNNING.value
return RuntimeDisplayStatus.ACCEPTED.value
+2
View File
@@ -1,5 +1,7 @@
"""Sub-mode runtime compatibility module."""
"""Compatibility exports for the legacy /api/swarms runtime surface."""
from .router import swarms_router
__all__ = ["swarms_router"]
+172
View File
@@ -2,10 +2,12 @@
import base64
import hashlib
import json
import logging
import mimetypes
import os
import re
import zipfile
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
@@ -47,6 +49,21 @@ class StoredArtifact:
content_hash: str
@dataclass(frozen=True)
class StoredProjectArtifact:
"""Metadata for a persisted project-folder artifact."""
artifact_id: str
uri: str
root_dir: str
artifact_dir: Path
manifest_path: Path
archive_path: Path
file_count: int
directory_count: int
content_hash: str
def sanitize_artifact_id(value: str) -> str:
"""Return a filesystem-safe identifier while preserving readable IDs."""
cleaned = _SAFE_ID_RE.sub("_", value or "").strip("._-")
@@ -65,11 +82,32 @@ def runtime_artifact_download_path(swarm_id: str, artifact_id: str) -> str:
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/content"
def runtime_artifact_manifest_path(swarm_id: str, artifact_id: str) -> str:
"""Build the HTTP path for a project-folder manifest."""
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/manifest"
def runtime_artifact_archive_path(swarm_id: str, artifact_id: str) -> str:
"""Build the HTTP path for a project-folder archive."""
return f"/api/swarms/{sanitize_artifact_id(swarm_id)}/artifacts/{sanitize_artifact_id(artifact_id)}/archive.zip"
def runtime_artifact_file_path(swarm_id: str, artifact_id: str, file_path: str) -> str:
"""Build the HTTP path for a single file inside a project-folder artifact."""
safe_swarm_id = sanitize_artifact_id(swarm_id)
safe_artifact_id = sanitize_artifact_id(artifact_id)
return f"/api/swarms/{safe_swarm_id}/artifacts/{safe_artifact_id}/files/{file_path.lstrip('/')}"
def _artifact_dir(swarm_id: str) -> Path:
base_dir = Path(settings.RUNTIME_ARTIFACT_DIR)
return base_dir / sanitize_artifact_id(swarm_id)
def _project_artifact_dir(swarm_id: str, artifact_id: str) -> Path:
return _artifact_dir(swarm_id) / f"{sanitize_artifact_id(artifact_id)}.project"
def _extension_for_mime_type(mime_type: str) -> str:
if mime_type == "text/x-diff":
return ".patch"
@@ -275,6 +313,140 @@ def load_runtime_artifact(swarm_id: str, artifact_id: str) -> Optional[StoredArt
)
def _safe_project_file_path(value: str) -> str:
normalized = (value or "").strip().replace("\\", "/").lstrip("/")
normalized = re.sub(r"/+", "/", normalized)
if not normalized or normalized in {".", ".."} or normalized.startswith("../") or "/../" in normalized:
raise ValueError("invalid project file path")
return normalized
def _directory_count_for_files(root_dir: str, files: dict[str, str]) -> int:
directories = {root_dir}
for relative_path in files:
parts = relative_path.split("/")[:-1]
current = root_dir
for part in parts:
current = f"{current}/{part}" if current else part
directories.add(current)
return len(directories)
def store_project_artifact(
swarm_id: str,
artifact_id: str,
*,
root_dir: str,
files: dict[str, str],
) -> StoredProjectArtifact:
"""Persist a project-folder artifact with manifest and archive."""
safe_artifact_id = sanitize_artifact_id(artifact_id)
safe_root_dir = sanitize_artifact_id(root_dir)
if not files:
raise ValueError("project artifact requires at least one file")
artifact_dir = _project_artifact_dir(swarm_id, safe_artifact_id)
if artifact_dir.exists():
for child in sorted(artifact_dir.rglob("*"), reverse=True):
if child.is_file():
child.unlink()
elif child.is_dir():
child.rmdir()
artifact_dir.mkdir(parents=True, exist_ok=True)
root_path = artifact_dir / safe_root_dir
root_path.mkdir(parents=True, exist_ok=True)
normalized_files: dict[str, str] = {}
for relative_path, content in files.items():
safe_relative_path = _safe_project_file_path(relative_path)
normalized_files[safe_relative_path] = content or ""
file_path = root_path / safe_relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content or "", encoding="utf-8")
manifest = {
"artifact_id": safe_artifact_id,
"artifact_type": "project_folder",
"root_dir": safe_root_dir,
"file_count": len(normalized_files),
"directory_count": _directory_count_for_files(safe_root_dir, normalized_files),
"files": [
{
"path": relative_path,
"size_bytes": len(content.encode("utf-8")),
"mime_type": mimetypes.guess_type(relative_path)[0] or "text/plain",
}
for relative_path, content in sorted(normalized_files.items())
],
}
manifest_path = artifact_dir / "manifest.json"
manifest_bytes = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
manifest_path.write_bytes(manifest_bytes)
archive_path = artifact_dir / f"{safe_artifact_id}.zip"
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for relative_path in sorted(normalized_files):
zf.write(root_path / relative_path, arcname=f"{safe_root_dir}/{relative_path}")
zf.write(manifest_path, arcname=f"{safe_root_dir}/heicode-artifact.json")
hash_source = hashlib.sha256()
hash_source.update(manifest_bytes)
for relative_path, content in sorted(normalized_files.items()):
hash_source.update(relative_path.encode("utf-8"))
hash_source.update((content or "").encode("utf-8"))
content_hash = "sha256:" + hash_source.hexdigest()
return StoredProjectArtifact(
artifact_id=safe_artifact_id,
uri=runtime_artifact_uri(swarm_id, safe_artifact_id),
root_dir=safe_root_dir,
artifact_dir=artifact_dir,
manifest_path=manifest_path,
archive_path=archive_path,
file_count=len(normalized_files),
directory_count=manifest["directory_count"],
content_hash=content_hash,
)
def load_runtime_project_artifact(swarm_id: str, artifact_id: str) -> Optional[StoredProjectArtifact]:
"""Load project-folder artifact metadata if it exists locally."""
safe_swarm_id = sanitize_artifact_id(swarm_id)
safe_artifact_id = sanitize_artifact_id(artifact_id)
artifact_dir = _project_artifact_dir(safe_swarm_id, safe_artifact_id)
manifest_path = artifact_dir / "manifest.json"
archive_path = artifact_dir / f"{safe_artifact_id}.zip"
if not artifact_dir.exists() or not manifest_path.exists() or not archive_path.exists():
return None
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
return StoredProjectArtifact(
artifact_id=safe_artifact_id,
uri=runtime_artifact_uri(safe_swarm_id, safe_artifact_id),
root_dir=manifest.get("root_dir") or safe_artifact_id,
artifact_dir=artifact_dir,
manifest_path=manifest_path,
archive_path=archive_path,
file_count=int(manifest.get("file_count") or 0),
directory_count=int(manifest.get("directory_count") or 0),
content_hash="sha256:" + hashlib.sha256(manifest_path.read_bytes() + archive_path.read_bytes()).hexdigest(),
)
def load_runtime_project_file(swarm_id: str, artifact_id: str, relative_path: str) -> Optional[Path]:
"""Resolve a single file inside a stored project-folder artifact."""
project = load_runtime_project_artifact(swarm_id, artifact_id)
if not project:
return None
safe_relative_path = _safe_project_file_path(relative_path)
file_path = project.artifact_dir / project.root_dir / safe_relative_path
if not file_path.exists() or not file_path.is_file():
return None
return file_path
def runtime_uri_parts(uri: str) -> Optional[tuple[str, str]]:
"""Parse runtime://<swarm_id>/artifacts/<artifact_id> URIs."""
if not uri or not uri.startswith("runtime://"):
+1 -1
View File
@@ -40,7 +40,7 @@ def _utc_iso() -> str:
def _env_name_from_secret_ref(secret_ref: str) -> str:
"""Map azkv secret names to ENV names, e.g. agnet-callback-key."""
"""Map azkv secret names to ENV names, e.g. agent-callback-key."""
secret_name = secret_ref.rstrip("/").split("/")[-1]
return secret_name.upper().replace("-", "_")
+57 -6
View File
@@ -17,7 +17,7 @@ class AgentConfig(BaseModel):
@model_validator(mode="before")
@classmethod
def normalize_sub_mode_agent(cls, data: Any) -> Any:
"""Accept Manager sub-mode agent fields when /api/swarms is used."""
"""Accept Manager sub-mode agent fields when the compatibility API is used."""
if not isinstance(data, dict):
return data
data = dict(data)
@@ -59,7 +59,7 @@ class CallbackConfig(BaseModel):
class SwarmCreateRequest(BaseModel):
"""Request model for creating a sub-mode runtime run."""
"""Compatibility request model for creating a sub-mode runtime run."""
task_description: str = Field(..., description="Task description")
project_context: Optional[ProjectContext] = Field(None, description="Project context")
agents: List[AgentConfig] = Field(..., description="Agent configurations")
@@ -77,12 +77,41 @@ class SwarmAgentInfo(BaseModel):
service_url: Optional[str] = None
current_task: Optional[str] = None
output: Optional[str] = None
tokens: int = 0
tools: int = 0
elapsed_seconds: int = 0
artifact_ids: List[str] = Field(default_factory=list)
current_action: Optional[str] = None
class SwarmPhaseAgentInfo(BaseModel):
"""Per-phase agent information for workflow/work displays."""
agent_id: str
role: str
status: str
tokens: int = 0
tools: int = 0
elapsed_seconds: int = 0
artifact_ids: List[str] = Field(default_factory=list)
current_action: Optional[str] = None
error: Optional[str] = None
summary: Optional[str] = None
class SwarmPhaseInfo(BaseModel):
"""Runtime phase summary with agent-level details."""
name: str
status: str
agents: List[SwarmPhaseAgentInfo] = Field(default_factory=list)
artifact_ids: List[str] = Field(default_factory=list)
summary: Optional[str] = None
class SwarmCreateResponse(BaseModel):
"""Response model for sub-mode runtime creation."""
"""Compatibility response model for sub-mode runtime creation."""
deployment_id: Optional[str] = None
swarm_id: str
mode: Optional[str] = None
status: str
agents: List[SwarmAgentInfo]
created_at: datetime
@@ -97,13 +126,15 @@ class SwarmMetrics(BaseModel):
class SwarmStatusResponse(BaseModel):
"""Response model for sub-mode runtime status."""
"""Compatibility response model for sub-mode runtime status."""
deployment_id: Optional[str] = None
swarm_id: str
mode: Optional[str] = None
status: str
phase: Optional[str] = None
progress: int
agents: List[SwarmAgentInfo]
phases: List[SwarmPhaseInfo] = Field(default_factory=list)
metrics: SwarmMetrics
artifacts: List[Dict[str, Any]] = []
error_message: Optional[str] = None
@@ -112,13 +143,13 @@ class SwarmStatusResponse(BaseModel):
class SwarmStopRequest(BaseModel):
"""Request model for stopping a sub-mode runtime run."""
"""Compatibility request model for stopping a sub-mode runtime run."""
reason: Optional[str] = Field(None, description="Reason for stopping")
cleanup: bool = Field(default=True, description="Whether to cleanup K8s resources")
class SwarmStopResponse(BaseModel):
"""Response model for stopping a sub-mode runtime run."""
"""Compatibility response model for stopping a sub-mode runtime run."""
deployment_id: Optional[str] = None
swarm_id: str
status: str
@@ -139,3 +170,23 @@ class ApprovalDecisionRequest(BaseModel):
credential_ref: Optional[str] = None
lease_id: Optional[str] = None
lease_expires_at: Optional[int] = None
class ArtifactEditRequest(BaseModel):
"""Accepted project revision notification from Manager."""
artifact_id: str
project_revision: int
base_project_revision: Optional[int] = None
base_content_hash: Optional[str] = None
manifest_uri: str
archive_uri: str
source: str
class ArtifactEditResponse(BaseModel):
"""Runtime acknowledgement for an accepted artifact revision."""
success: bool = True
deployment_id: str
artifact_id: str
project_revision: int
status: str
+645 -11
View File
@@ -3,19 +3,39 @@
import asyncio
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Any, Optional
from sqlalchemy.orm import Session
from database import Swarm, SwarmAgent, SwarmMessage, SwarmStatus, SwarmAgentStatus
from k8s_manager import K8sManager
from api.agnet.vault_client import vault_client
from .agent_client import SwarmAgentClient
from .artifact_store import store_text_artifact, runtime_artifact_uri, runtime_artifact_download_path
from .artifact_store import (
store_project_artifact,
store_text_artifact,
runtime_artifact_archive_path,
runtime_artifact_download_path,
runtime_artifact_file_path,
runtime_artifact_manifest_path,
runtime_artifact_uri,
)
from .callback_client import CallbackDeliveryClient
logger = logging.getLogger(__name__)
_CODE_BLOCK_RE = re.compile(r"```(?P<lang>[a-zA-Z0-9_+-]*)\n(?P<body>.*?)```", re.DOTALL)
_FILENAME_HINT_RE = re.compile(
r"^\s*(?:#|//|/\*+|\*|--)?\s*(?P<path>[A-Za-z0-9_.\-/]+\.[A-Za-z0-9_]+)\s*(?:\*/)?\s*$"
)
PHASE_MAP = {
"planning": ("requirements", "agent_running"),
@@ -46,6 +66,7 @@ class SwarmOrchestrator:
self.callback: Optional[CallbackDeliveryClient] = None
self.correlation_id: Optional[str] = None
self.k8s_manager: Optional[K8sManager] = None
self._git_workspace_dir: Optional[Path] = None
async def initialize(self) -> bool:
"""
@@ -55,13 +76,13 @@ class SwarmOrchestrator:
True if successful, False otherwise
"""
try:
# Load swarm from database
# Load the internal runtime-run record from the database.
self.swarm = self.db.query(Swarm).filter(
Swarm.swarm_id == self.swarm_id
).first()
if not self.swarm:
logger.error(f"Swarm {self.swarm_id} not found")
logger.error(f"Runtime deployment {self.swarm_id} not found")
return False
project_context = self.swarm.project_context or {}
@@ -73,7 +94,7 @@ class SwarmOrchestrator:
self.db.commit()
await self._emit_status("initializing")
logger.info(f"Initializing swarm {self.swarm_id}")
logger.info(f"Initializing sub-mode runtime deployment {self.swarm_id}")
# Get all agents for this swarm
swarm_agents = self.db.query(SwarmAgent).filter(
@@ -154,7 +175,10 @@ class SwarmOrchestrator:
"model": agent.model,
"capabilities": agent.capabilities or [],
"system_prompt": agent.system_prompt,
"billing_context": (self.swarm.project_context or {}).get("billing_context") or {},
"billing_context": {
**((self.swarm.project_context or {}).get("billing_context") or {}),
"max_tokens": ((self.swarm.project_context or {}).get("budget") or {}).get("max_tokens"),
},
}
return self.k8s_manager.deploy_swarm_agent(
self.swarm_id,
@@ -184,6 +208,7 @@ class SwarmOrchestrator:
result = await self._execute_hybrid()
artifacts = self._ensure_result_artifacts(result)
artifacts = await self._attach_git_delivery_refs(artifacts)
# Update swarm status
self.swarm.status = SwarmStatus.COMPLETED
@@ -211,6 +236,7 @@ class SwarmOrchestrator:
await self._emit_artifacts(failure_artifacts)
await self._emit_phase("failed", "Swarm execution failed")
await self._emit_status("failed", {"error": str(e)})
await self._emit_usage()
raise
async def _execute_sequential(self) -> Dict[str, Any]:
@@ -459,8 +485,81 @@ class SwarmOrchestrator:
if isinstance(response, dict) and response.get("error"):
error = response.get("error") or {}
message_text = error.get("message") if isinstance(error, dict) else str(error)
raise RuntimeError(message_text or "A2A agent returned an error response")
error_data = error.get("data") if isinstance(error, dict) else {}
if self._is_retryable_runtime_error(message_text or "") and agent_record:
retry_task = self._build_retry_task(agent_record, task, message_text or "gateway timeout")
retry_message = SwarmMessage(
message_id=str(uuid.uuid4()),
swarm_id=self.swarm_id,
from_agent_id=None,
to_agent_id=client.agent_id,
message_type="task_retry",
content=retry_task,
message_metadata={
"retry_reason": message_text,
"retryable": True,
},
)
self.db.add(retry_message)
self.db.commit()
self.swarm.total_messages += 1
agent_record.current_task = retry_task
self.db.commit()
await self._emit_tool_event(
"sk_tool.called",
client.agent_id,
{
"tool_name": "agent_task_retry",
"tool_invocation_id": retry_message.message_id,
"summary": "Retrying agent task with a reduced output contract",
"arguments_redacted": True,
},
)
response = await client.send_message({"text": retry_task})
if not (isinstance(response, dict) and response.get("error")):
usage = self._extract_usage(response)
model_metadata = self._extract_model_metadata(response)
await self._emit_tool_event(
"sk_tool.completed",
client.agent_id,
{
"tool_name": "agent_task_retry",
"tool_invocation_id": retry_message.message_id,
"summary": "Agent retry task completed",
"result_preview": str(response)[:500],
"model_usage": usage,
"newapi_request_id": model_metadata.get("newapi_request_id"),
"model_api_format": model_metadata.get("api_format"),
},
)
if isinstance(response, dict) and response.get("error"):
error = response.get("error") or {}
message_text = error.get("message") if isinstance(error, dict) else str(error)
error_data = error.get("data") if isinstance(error, dict) else {}
if isinstance(error_data, dict):
error_message = SwarmMessage(
message_id=str(uuid.uuid4()),
swarm_id=self.swarm_id,
from_agent_id=client.agent_id,
to_agent_id=None,
message_type="error",
content=message_text or "A2A agent returned an error response",
message_metadata={
"newapi_request_id": error_data.get("request_id"),
"model_status_code": error_data.get("status_code"),
"model_response_preview": (error_data.get("response_text") or "")[:1000],
},
)
self.db.add(error_message)
self.db.commit()
runtime_error = RuntimeError(message_text or "A2A agent returned an error response")
if isinstance(error_data, dict):
setattr(runtime_error, "request_id", error_data.get("request_id"))
raise runtime_error
usage = self._extract_usage(response)
model_metadata = self._extract_model_metadata(response)
if self.swarm and usage["total_tokens"]:
self.swarm.tokens_used += usage["total_tokens"]
self.db.commit()
@@ -473,6 +572,8 @@ class SwarmOrchestrator:
"summary": "Agent task completed",
"result_preview": str(response)[:500],
"model_usage": usage,
"newapi_request_id": model_metadata.get("newapi_request_id"),
"model_api_format": model_metadata.get("api_format"),
},
)
@@ -484,7 +585,10 @@ class SwarmOrchestrator:
to_agent_id=None, # To orchestrator
message_type="response",
content=str(response),
message_metadata={}
message_metadata={
"model_usage": usage,
**model_metadata,
},
)
self.db.add(response_message)
self.db.commit()
@@ -512,6 +616,8 @@ class SwarmOrchestrator:
"status": "completed",
"summary": self._response_summary(response),
"runtime_deployment_id": self.swarm_id,
"newapi_request_id": model_metadata.get("newapi_request_id"),
"model_usage": usage,
},
)
@@ -532,6 +638,7 @@ class SwarmOrchestrator:
"tool_name": "agent_task",
"summary": "Agent task failed",
"error": str(e),
"newapi_request_id": self._request_id_from_error(e),
},
)
await self._emit(
@@ -543,6 +650,7 @@ class SwarmOrchestrator:
"status": "failed",
"summary": str(e),
"runtime_deployment_id": self.swarm_id,
"newapi_request_id": self._request_id_from_error(e),
},
)
raise
@@ -600,6 +708,11 @@ class SwarmOrchestrator:
for client in self.agents.values():
await client.close()
self.agents.clear()
if self._git_workspace_dir:
workspace_root = self._git_workspace_dir.parent
if workspace_root.exists():
shutil.rmtree(workspace_root, ignore_errors=True)
self._git_workspace_dir = None
def _callback_context(self) -> Dict[str, Any]:
"""Return callback context stored on the swarm."""
@@ -676,13 +789,20 @@ class SwarmOrchestrator:
context = self._callback_context()
budget = context.get("budget") or {}
billing_context = context.get("billing_context") or {}
usage = self._aggregate_model_usage_from_messages()
if self.swarm and usage["total_tokens"] > self.swarm.tokens_used:
self.swarm.tokens_used = usage["total_tokens"]
self.db.commit()
model_tokens = self.swarm.tokens_used if self.swarm else usage["total_tokens"]
if not model_tokens:
return
await self._emit(
"budget.alert",
payload={
"model_id": billing_context.get("default_model_id") or "unknown",
"model_tokens": self.swarm.tokens_used if self.swarm else 0,
"prompt_tokens": 0,
"completion_tokens": 0,
"model_tokens": model_tokens,
"prompt_tokens": usage["prompt_tokens"],
"completion_tokens": usage["completion_tokens"],
"model_cost_usd": 0,
"runtime_seconds": 0,
"cpu_core_seconds": 0,
@@ -701,6 +821,27 @@ class SwarmOrchestrator:
},
)
def _aggregate_model_usage_from_messages(self) -> Dict[str, int]:
"""Aggregate persisted model usage metadata for Runtime usage callbacks."""
totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
messages = (
self.db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == self.swarm_id)
.all()
)
for message in messages:
usage = (message.message_metadata or {}).get("model_usage") or {}
if not isinstance(usage, dict):
continue
totals["prompt_tokens"] += int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
totals["completion_tokens"] += int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
totals["total_tokens"] += int(
usage.get("total_tokens")
or (usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
+ (usage.get("completion_tokens") or usage.get("output_tokens") or 0)
)
return totals
async def _emit(
self,
event_type: str,
@@ -750,8 +891,63 @@ class SwarmOrchestrator:
"Agile context: "
+ json.dumps(project_context["agile_context"], ensure_ascii=False, sort_keys=True)
)
prompt_parts.append(self._role_output_contract(agent.role))
return "\n".join(prompt_parts)
def _role_output_contract(self, role: Optional[str]) -> str:
"""Return a compact output contract tuned for runtime stability."""
normalized_role = (role or "worker").lower()
common = (
"Output contract: keep the response concise and implementation-oriented. "
"Prefer a minimal viable skeleton over a full project. "
"Do not exceed 12 bullets. Do not exceed 120 lines of code total."
)
role_contracts = {
"backend": (
"Return only: 1) a short API/data-model summary, "
"2) one compact Python/FastAPI code skeleton, "
"3) a brief test checklist."
),
"frontend": (
"Return only: 1) a short UI/component summary, "
"2) one compact React/JSX/CSS skeleton, "
"3) a brief interaction checklist."
),
"reviewer": (
"Return only: 1) major risks, 2) test cases, 3) release/blocking notes. "
"No long prose and no large code blocks."
),
"architect": (
"Return only: 1) architecture outline, 2) core modules, 3) key interfaces. "
"No large code blocks."
),
}
fallback = "Return a short implementation summary and one minimal code skeleton if needed."
return f"{common} {role_contracts.get(normalized_role, fallback)}"
def _build_retry_task(self, agent: Optional[SwarmAgent], original_task: str, error: str) -> str:
"""Build a smaller retry prompt when the first generation overloads the model gateway."""
role = agent.role if agent else "worker"
retry_instructions = (
"Previous attempt failed at the model gateway. Retry with a much smaller response. "
"Return only the single most important implementation skeleton for your role. "
"Limit output to at most 6 bullets and at most 60 lines of code total. "
"Skip optional explanations, examples, and secondary files."
)
return f"{original_task}\nRole: {role}\nRetry reason: {error}\n{retry_instructions}"
def _is_retryable_runtime_error(self, message: str) -> bool:
"""Classify model-gateway errors that benefit from a smaller retry."""
normalized = (message or "").lower()
retry_markers = (
"504 gateway time-out",
"504 gateway timeout",
"timed out",
"timeout",
"upstream request timeout",
)
return any(marker in normalized for marker in retry_markers)
def _response_summary(self, response: Any) -> str:
"""Return a readable response summary for logs, artifacts, and callbacks."""
text = self._extract_deliverable_text(response)
@@ -845,6 +1041,42 @@ class SwarmOrchestrator:
"total_tokens": total_tokens,
}
def _extract_model_metadata(self, response: Any) -> Dict[str, Any]:
"""Extract model request metadata from nested A2A responses."""
metadata = self._find_model_metadata(response) or {}
request_id = (
metadata.get("newapi_request_id")
or metadata.get("request_id")
or metadata.get("response_id")
)
return {
"newapi_request_id": request_id,
"response_id": metadata.get("response_id"),
"model": metadata.get("model"),
"api_format": metadata.get("api_format"),
"endpoint": metadata.get("endpoint"),
}
def _find_model_metadata(self, value: Any) -> Optional[Dict[str, Any]]:
"""Find nested model metadata emitted by an A2A agent."""
if isinstance(value, dict):
if any(key in value for key in ("newapi_request_id", "request_id", "response_id", "api_format")):
return value
for nested in value.values():
found = self._find_model_metadata(nested)
if found:
return found
elif isinstance(value, list):
for item in value:
found = self._find_model_metadata(item)
if found:
return found
return None
def _request_id_from_error(self, error: Exception) -> Optional[str]:
"""Best-effort extraction for errors raised after model calls."""
return getattr(error, "request_id", None)
def _find_usage_dict(self, value: Any) -> Optional[Dict[str, Any]]:
"""Find a nested usage-like dict containing token counters."""
if isinstance(value, dict):
@@ -865,16 +1097,69 @@ class SwarmOrchestrator:
def _build_artifact(self, agent: SwarmAgent, response: Any, index: int) -> Dict[str, Any]:
"""Convert an agent response into a Heicode-visible artifact record."""
role = agent.role or "worker"
artifact_type = "code_patch" if role in {"backend", "frontend", "coder", "engineer"} else "document"
is_structural_code_role = role in {"backend", "frontend", "coder", "engineer", "fullstack"}
artifact_type = "project_folder" if is_structural_code_role else "document"
artifact_id = f"art_{self.swarm_id}_{role}_{index + 1}"
content = self._extract_deliverable_text(response)
if not content:
content = json.dumps(response, ensure_ascii=False, indent=2) if isinstance(response, (dict, list)) else str(response)
if is_structural_code_role:
files, root_dir = self._project_files_from_content(role, content)
stored_project = self._store_project_artifact(artifact_id, root_dir, files)
metadata = {
"redacted": True,
"agent_role": role,
"source_agent_role": role,
"runtime_deployment_id": self.swarm_id,
"summary_only": False,
"artifact_layout": "project_folder",
"primary_read_path": "manifest",
"root_dir": stored_project.root_dir if stored_project else root_dir,
"file_count": stored_project.file_count if stored_project else len(files),
"directory_count": stored_project.directory_count if stored_project else 1,
"manifest_uri": runtime_artifact_manifest_path(self.swarm_id, artifact_id),
"archive_uri": runtime_artifact_archive_path(self.swarm_id, artifact_id),
"project_revision": 1,
}
if stored_project:
metadata.update(
{
"content_hash": stored_project.content_hash,
"download_path": runtime_artifact_archive_path(self.swarm_id, artifact_id),
"files_base_uri": runtime_artifact_file_path(self.swarm_id, artifact_id, ""),
"delivery_ref": {
"kind": "runtime_artifact",
"artifact_id": artifact_id,
"project_revision": 1,
},
}
)
git_ref = self._git_ref_metadata()
if git_ref:
metadata["git_ref"] = git_ref
return {
"artifact_id": artifact_id,
"artifact_type": artifact_type,
"title": f"{role} task delivery",
"summary": content[:1000],
"uri": stored_project.uri if stored_project else runtime_artifact_uri(self.swarm_id, artifact_id),
"agent_instance_id": agent.agent_id,
"mime_type": "application/zip",
"size_bytes": stored_project.archive_path.stat().st_size if stored_project else len(content.encode("utf-8")),
"stage": "development",
"checkpoint": "artifact_ready",
"metadata": metadata,
}
stored = self._store_artifact_content(artifact_id, content)
metadata = {
"redacted": True,
"agent_role": role,
"source_agent_role": role,
"runtime_deployment_id": self.swarm_id,
"summary_only": False,
"artifact_layout": "single_file_content",
"primary_read_path": "content",
}
if stored:
metadata.update(
@@ -883,6 +1168,9 @@ class SwarmOrchestrator:
"download_path": runtime_artifact_download_path(self.swarm_id, artifact_id),
}
)
git_ref = self._git_ref_metadata()
if git_ref:
metadata["git_ref"] = git_ref
return {
"artifact_id": artifact_id,
"artifact_type": artifact_type,
@@ -959,6 +1247,11 @@ class SwarmOrchestrator:
"source": "agent-manager-sub-mode-runtime",
"runtime_deployment_id": self.swarm_id,
"agent_count": len(agents),
"synthesized": True,
"summary_only": True,
"artifact_layout": "single_file_content",
"primary_read_path": "content",
"project_revision": 0,
}
if stored:
metadata.update(
@@ -999,3 +1292,344 @@ class SwarmOrchestrator:
except Exception as e:
logger.warning("Failed to persist runtime artifact %s: %s", artifact_id, e)
return None
def _store_project_artifact(self, artifact_id: str, root_dir: str, files: Dict[str, str]):
"""Persist a project-folder artifact without blocking runtime completion."""
try:
return store_project_artifact(self.swarm_id, artifact_id, root_dir=root_dir, files=files)
except Exception as e:
logger.warning("Failed to persist runtime project artifact %s: %s", artifact_id, e)
return None
def _git_context(self) -> Optional[Dict[str, Any]]:
"""Return repository context when Runtime should write delivery branches."""
context = self._callback_context()
repo_url = context.get("repo_url")
git_binding_id = context.get("git_binding_id")
resource_grants = context.get("resource_grants") or []
git_grant = self._find_git_resource_grant(resource_grants, git_binding_id)
if not repo_url and git_grant:
repo_url = (
git_grant.get("external_ref")
or git_grant.get("repo_url")
or (git_grant.get("metadata") or {}).get("repo_url")
)
if not repo_url:
return None
return {
"repo_url": repo_url,
"base_branch": context.get("branch") or "main",
"git_binding_id": git_binding_id or (git_grant or {}).get("resource_id") or (git_grant or {}).get("grant_id"),
"git_grant": git_grant,
}
def _find_git_resource_grant(self, resource_grants: List[Dict[str, Any]], git_binding_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""Select the git resource grant attached to this runtime task."""
if git_binding_id:
for grant in resource_grants:
if git_binding_id in {
grant.get("resource_id"),
grant.get("grant_id"),
grant.get("binding_id"),
}:
return grant
for grant in resource_grants:
resource_type = (grant.get("resource_type") or grant.get("type") or "").lower()
if resource_type == "git":
return grant
return None
async def _git_credentials(self) -> Optional[tuple[str, str]]:
"""Resolve git credentials from Manager-provided resource grants first, then env fallback."""
git_context = self._git_context() or {}
git_grant = git_context.get("git_grant") or {}
metadata = git_grant.get("metadata") or {}
username = (
metadata.get("username")
or git_grant.get("username")
or os.getenv("GITEE_USERNAME")
or ""
)
secret_ref = git_grant.get("secret_ref") or git_grant.get("ref")
if secret_ref:
secret_value = await vault_client.get_secret(secret_ref)
parsed = self._parse_git_secret(secret_value, username)
if parsed:
return parsed
# Compatibility fallback for older deployments that still rely on env injection.
token = os.getenv("GITEE_TOKEN") or ""
password = os.getenv("GITEE_PASSWORD") or ""
if token and token != "your-gitee-token":
return username or "oauth2", token
if password and password != "your-gitee-password":
return username or "git", password
return None
def _parse_git_secret(self, secret_value: Any, default_username: str) -> Optional[tuple[str, str]]:
"""Parse a git secret payload into username/password credentials."""
if not secret_value:
return None
if isinstance(secret_value, str):
stripped = secret_value.strip()
if not stripped:
return None
if stripped.startswith("{"):
try:
secret_value = json.loads(stripped)
except Exception:
return default_username or "oauth2", stripped
else:
return default_username or "oauth2", stripped
if isinstance(secret_value, dict):
username = (
secret_value.get("username")
or secret_value.get("user")
or default_username
or "oauth2"
)
password = (
secret_value.get("token")
or secret_value.get("password")
or secret_value.get("pat")
or secret_value.get("access_token")
)
if password:
return username, str(password)
return None
def _inject_git_credentials(self, repo_url: str, username: str, password: str) -> str:
"""Inject credentials into an HTTP(S) git URL without persisting them."""
if "://" not in repo_url:
return repo_url
proto, rest = repo_url.split("://", 1)
if "@" in rest:
rest = rest.split("@", 1)[1]
return f"{proto}://{username}:{password}@{rest}"
def _run_git(self, args: List[str], cwd: Path, *, timeout: int = 120) -> subprocess.CompletedProcess:
"""Run a git command and raise on failure."""
result = subprocess.run(
args,
cwd=str(cwd),
capture_output=True,
text=True,
timeout=timeout,
)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or f"git command failed: {' '.join(args)}")
return result
async def _ensure_git_workspace(self) -> Optional[Path]:
"""Clone the target repository once and reuse it for role and delivery branches."""
git_context = self._git_context()
credentials = await self._git_credentials()
if not git_context or not credentials:
return None
if self._git_workspace_dir and self._git_workspace_dir.exists():
return self._git_workspace_dir
username, password = credentials
repo_url = git_context["repo_url"]
auth_url = self._inject_git_credentials(repo_url, username, password)
workspace = Path(tempfile.mkdtemp(prefix=f"swarm_git_{self.swarm_id}_"))
repo_dir = workspace / "repo"
self._run_git(["git", "clone", "--branch", git_context["base_branch"], auth_url, str(repo_dir)], workspace, timeout=180)
self._run_git(["git", "remote", "set-url", "origin", repo_url], repo_dir)
self._run_git(["git", "config", "user.name", "heicode-agent"], repo_dir)
self._run_git(["git", "config", "user.email", "bot@heicode.local"], repo_dir)
self._git_workspace_dir = repo_dir
return repo_dir
def _write_project_files_to_repo(self, repo_dir: Path, files: Dict[str, str]) -> None:
"""Materialize project artifact files into a git workspace."""
for relative_path, content in files.items():
file_path = repo_dir / relative_path
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
def _git_repo_files_from_artifact(self, artifact: Dict[str, Any]) -> Dict[str, str]:
"""Read back stored project artifact files for git delivery."""
artifact_id = artifact.get("artifact_id")
project = store = None
from .artifact_store import load_runtime_project_artifact
project = load_runtime_project_artifact(self.swarm_id, artifact_id)
if not project:
return {}
files = {}
root_dir = project.artifact_dir / project.root_dir
for file_path in sorted(root_dir.rglob("*")):
if file_path.is_file():
rel = file_path.relative_to(root_dir).as_posix()
files[rel] = file_path.read_text(encoding="utf-8", errors="replace")
return files
async def _attach_git_delivery_refs(self, artifacts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Best-effort git branch/commit materialization for project-folder artifacts."""
repo_dir = await self._ensure_git_workspace()
if not repo_dir:
return artifacts
git_context = self._git_context() or {}
credentials = await self._git_credentials()
if not credentials:
return artifacts
username, password = credentials
origin_url = self._run_git(["git", "remote", "get-url", "origin"], repo_dir).stdout.strip()
auth_url = self._inject_git_credentials(origin_url, username, password)
base_branch = git_context["base_branch"]
role_branches = []
updated_artifacts = []
base_git_ref = self._git_ref_metadata() or {}
for artifact in artifacts:
metadata = artifact.get("metadata") or {}
if metadata.get("artifact_layout") != "project_folder":
updated_artifacts.append(artifact)
continue
role = metadata.get("source_agent_role") or metadata.get("agent_role") or "worker"
role_branch = f"agent/{role}/{self.swarm_id}"
self._run_git(["git", "checkout", base_branch], repo_dir)
self._run_git(["git", "checkout", "-B", role_branch], repo_dir)
files = self._git_repo_files_from_artifact(artifact)
if not files:
updated_artifacts.append(artifact)
continue
self._write_project_files_to_repo(repo_dir, files)
self._run_git(["git", "add", "-A"], repo_dir)
commit_message = f"sub-mode {role} delivery for {self.swarm_id}"
commit_result = subprocess.run(
["git", "commit", "-m", commit_message],
cwd=str(repo_dir),
capture_output=True,
text=True,
timeout=120,
)
if commit_result.returncode != 0 and "nothing to commit" not in (commit_result.stdout + commit_result.stderr).lower():
raise RuntimeError(commit_result.stderr.strip() or commit_result.stdout.strip() or "git commit failed")
self._run_git(["git", "push", auth_url, role_branch], repo_dir, timeout=180)
commit_sha = self._run_git(["git", "rev-parse", "HEAD"], repo_dir).stdout.strip()
metadata["git_ref"] = {
"provider": metadata.get("git_ref", {}).get("provider") or base_git_ref.get("provider") or "git",
"repo_url": origin_url,
"base_branch": base_branch,
"branch": role_branch,
"commit_sha": commit_sha,
"git_binding_id": git_context.get("git_binding_id"),
}
artifact["metadata"] = metadata
updated_artifacts.append(artifact)
role_branches.append(role_branch)
if role_branches:
delivery_branch = f"delivery/{self.swarm_id}"
self._run_git(["git", "checkout", base_branch], repo_dir)
self._run_git(["git", "checkout", "-B", delivery_branch], repo_dir)
for role_branch in role_branches:
merge_result = subprocess.run(
["git", "merge", "--no-ff", "--no-edit", role_branch],
cwd=str(repo_dir),
capture_output=True,
text=True,
timeout=180,
)
if merge_result.returncode != 0:
raise RuntimeError(merge_result.stderr.strip() or merge_result.stdout.strip() or f"git merge failed for {role_branch}")
self._run_git(["git", "push", auth_url, delivery_branch], repo_dir, timeout=180)
delivery_sha = self._run_git(["git", "rev-parse", "HEAD"], repo_dir).stdout.strip()
if self.swarm:
context = self._callback_context()
context["delivery_branch"] = delivery_branch
context["delivery_commit_sha"] = delivery_sha
self.swarm.project_context = context
self.db.commit()
for artifact in updated_artifacts:
metadata = artifact.get("metadata") or {}
if metadata.get("artifact_layout") == "project_folder":
metadata["delivery_ref"] = {
"kind": "git_branch",
"branch": delivery_branch,
"commit_sha": delivery_sha,
"project_revision": metadata.get("project_revision", 1),
}
artifact["metadata"] = metadata
return updated_artifacts
def _project_files_from_content(self, role: str, content: str) -> tuple[Dict[str, str], str]:
"""Convert a code-oriented response into a minimal project-folder file set."""
root_dir = f"{role}-delivery"
files: Dict[str, str] = {}
for match in _CODE_BLOCK_RE.finditer(content):
body = (match.group("body") or "").strip("\n")
if not body:
continue
lines = body.splitlines()
first_line = lines[0].strip() if lines else ""
filename_match = _FILENAME_HINT_RE.match(first_line)
if filename_match:
file_path = filename_match.group("path")
file_content = "\n".join(lines[1:]).lstrip("\n")
else:
file_path = self._default_file_path_for_role(role, match.group("lang") or "", len(files))
file_content = body
files[file_path] = file_content or ""
if not files:
files[self._default_file_path_for_role(role, "", 0)] = content.strip() + "\n"
files.setdefault("README.md", self._project_readme(role, content))
files.setdefault("heicode-artifact.json", json.dumps({"role": role, "artifact_layout": "project_folder"}, ensure_ascii=False, indent=2))
return files, root_dir
def _default_file_path_for_role(self, role: str, language: str, index: int) -> str:
"""Choose a stable fallback file path when the agent output omits file names."""
normalized_role = (role or "worker").lower()
language = (language or "").lower()
if normalized_role == "backend":
if language in {"python", "py"}:
return "backend/app.py" if index == 0 else f"backend/module_{index + 1}.py"
return "backend/implementation.txt"
if normalized_role == "frontend":
if language in {"jsx", "tsx", "javascript", "js", "typescript", "ts"}:
return "frontend/OrderPage.jsx" if index == 0 else f"frontend/component_{index + 1}.jsx"
if language == "css":
return "frontend/styles.css"
return "frontend/implementation.txt"
if normalized_role == "reviewer":
return "review/review.md"
return f"{normalized_role}/artifact_{index + 1}.txt"
def _project_readme(self, role: str, content: str) -> str:
"""Build a lightweight README for project-folder artifacts."""
return (
f"# {role} delivery\n\n"
"This project-folder artifact was synthesized by the sub-mode runtime from the agent response.\n\n"
"## Summary\n\n"
f"{content[:1500].strip()}\n"
)
def _git_ref_metadata(self) -> Optional[Dict[str, Any]]:
"""Build best-effort git delivery metadata from Manager-provided context."""
context = self._callback_context()
repo_url = context.get("repo_url")
if not repo_url:
return None
provider = "git"
repo_url_lower = str(repo_url).lower()
if "github.com" in repo_url_lower:
provider = "github"
elif "gitee" in repo_url_lower or "gitea" in repo_url_lower:
provider = "gitea"
return {
"provider": provider,
"repo_url": repo_url,
"base_branch": context.get("branch") or "main",
"branch": context.get("delivery_branch"),
"commit_sha": context.get("delivery_commit_sha"),
"git_binding_id": context.get("git_binding_id"),
}
+424 -28
View File
@@ -1,12 +1,15 @@
"""Sub-mode runtime compatibility router."""
"""Sub-mode runtime compatibility router for legacy /api/swarms clients."""
import json
import mimetypes
import uuid
from copy import deepcopy
from datetime import datetime, timedelta
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Request
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from sqlalchemy.orm.attributes import flag_modified
from database import (
get_db, Swarm, SwarmAgent, SwarmMessage,
@@ -15,19 +18,26 @@ from database import (
from k8s_manager import sanitize_k8s_name
from .artifact_store import (
load_azblob_artifact,
load_runtime_project_artifact,
load_runtime_project_file,
load_runtime_artifact,
runtime_artifact_archive_path,
runtime_artifact_download_path,
runtime_artifact_file_path,
runtime_artifact_manifest_path,
runtime_artifact_uri,
store_text_artifact,
)
from .models import (
SwarmCreateRequest, SwarmCreateResponse, SwarmStatusResponse,
SwarmStopRequest, SwarmStopResponse, SwarmAgentInfo, SwarmMetrics,
ApprovalDecisionRequest
ApprovalDecisionRequest, SwarmPhaseInfo, SwarmPhaseAgentInfo, ArtifactEditRequest, ArtifactEditResponse
)
from .orchestrator import SwarmOrchestrator
from api.status_projection import RuntimeDisplayStatus, project_runtime_run_status
from .callback_client import CallbackDeliveryClient
swarms_router = APIRouter(prefix="/api/swarms", tags=["swarms"])
swarms_router = APIRouter(prefix="/api/swarms", tags=["sub-mode-runtime-compatibility"])
def generate_swarm_id() -> str:
@@ -40,9 +50,100 @@ def generate_agent_id(role: str) -> str:
return f"agi_{role}_{uuid.uuid4().hex[:8]}"
def _extract_git_project_context(plan: Dict[str, Any], payload: Dict[str, Any]) -> Dict[str, Any]:
"""Derive git runtime context from Manager-provided resource grants and metadata."""
resource_grants = plan.get("resource_grants") or payload.get("resource_grants") or []
metadata = plan.get("metadata") or payload.get("metadata") or {}
git_binding_id = (
payload.get("git_binding_id")
or metadata.get("git_binding_id")
or metadata.get("resource_binding_id")
)
selected_git_grant = None
for grant in resource_grants:
grant_type = (grant.get("resource_type") or grant.get("type") or "").lower()
if grant_type != "git":
continue
if git_binding_id and git_binding_id not in {
grant.get("resource_id"),
grant.get("grant_id"),
grant.get("binding_id"),
}:
continue
selected_git_grant = grant
break
if not selected_git_grant:
return {}
grant_metadata = selected_git_grant.get("metadata") or {}
repo_url = (
grant_metadata.get("repo_url")
or selected_git_grant.get("external_ref")
or selected_git_grant.get("repo_url")
)
branch = (
payload.get("branch")
or plan.get("branch")
or grant_metadata.get("default_branch")
or grant_metadata.get("base_branch")
or "main"
)
allowed_paths = (
payload.get("allowed_paths")
or plan.get("allowed_paths")
or grant_metadata.get("allowed_paths")
)
return {
"repo_url": repo_url,
"branch": branch,
"git_binding_id": git_binding_id
or selected_git_grant.get("resource_id")
or selected_git_grant.get("grant_id"),
"allowed_paths": allowed_paths,
}
def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
"""Build response agent summaries for a swarm."""
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
artifacts_by_agent = {}
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
for artifact in (swarm.artifacts or []) if swarm else []:
agent_instance_id = artifact.get("agent_instance_id")
if agent_instance_id:
artifacts_by_agent.setdefault(agent_instance_id, []).append(artifact.get("artifact_id"))
def metrics_for_agent(agent_id: str):
agent_messages = [message for message in messages if agent_id in {message.from_agent_id, message.to_agent_id}]
tokens = 0
tools = 0
current_action = None
first_seen = None
last_seen = None
for message in agent_messages:
metadata = message.message_metadata or {}
usage = metadata.get("model_usage") or {}
tokens += int(usage.get("total_tokens") or 0)
if message.message_type in {"task", "task_retry", "response", "error"}:
tools += 1
if message.message_type in {"task", "task_retry"} and message.content:
current_action = message.content[:200]
if first_seen is None or message.created_at < first_seen:
first_seen = message.created_at
if last_seen is None or message.created_at > last_seen:
last_seen = message.created_at
elapsed = int((last_seen - first_seen).total_seconds()) if first_seen and last_seen else 0
return tokens, tools, elapsed, current_action
return [
SwarmAgentInfo(
agent_id=agent.agent_id,
@@ -52,11 +153,92 @@ def _agent_infos_for_swarm(db: Session, swarm_id: str) -> list[SwarmAgentInfo]:
service_url=agent.service_url,
current_task=agent.current_task,
output=agent.output,
tokens=metrics_for_agent(agent.agent_id)[0],
tools=metrics_for_agent(agent.agent_id)[1],
elapsed_seconds=metrics_for_agent(agent.agent_id)[2],
artifact_ids=artifacts_by_agent.get(agent.agent_id, []),
current_action=metrics_for_agent(agent.agent_id)[3] or agent.current_task,
)
for agent in agents
]
def _phases_for_swarm(db: Session, swarm: Swarm) -> list[SwarmPhaseInfo]:
"""Build workflow-style phase summaries from persisted runtime artifacts and messages."""
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm.swarm_id).all()
messages = (
db.query(SwarmMessage)
.filter(SwarmMessage.swarm_id == swarm.swarm_id)
.order_by(SwarmMessage.created_at.asc())
.all()
)
artifacts = swarm.artifacts or []
artifacts_by_agent = {}
for artifact in artifacts:
agent_instance_id = artifact.get("agent_instance_id")
if agent_instance_id:
artifacts_by_agent.setdefault(agent_instance_id, []).append(artifact.get("artifact_id"))
def metrics_for_agent(agent_id: str):
agent_messages = [message for message in messages if agent_id in {message.from_agent_id, message.to_agent_id}]
tokens = 0
tools = 0
current_action = None
first_seen = None
last_seen = None
for message in agent_messages:
metadata = message.message_metadata or {}
usage = metadata.get("model_usage") or {}
tokens += int(usage.get("total_tokens") or 0)
if message.message_type in {"task", "task_retry", "response", "error"}:
tools += 1
if message.message_type in {"task", "task_retry"} and message.content:
current_action = message.content[:200]
if first_seen is None or message.created_at < first_seen:
first_seen = message.created_at
if last_seen is None or message.created_at > last_seen:
last_seen = message.created_at
elapsed = int((last_seen - first_seen).total_seconds()) if first_seen and last_seen else 0
return tokens, tools, elapsed, current_action
agent_phase_infos = []
artifact_ids = []
for agent in agents:
tokens, tools, elapsed, current_action = metrics_for_agent(agent.agent_id)
agent_artifact_ids = artifacts_by_agent.get(agent.agent_id, [])
artifact_ids.extend(agent_artifact_ids)
agent_phase_infos.append(
SwarmPhaseAgentInfo(
agent_id=agent.agent_id,
role=agent.role,
status=agent.status.value,
tokens=tokens,
tools=tools,
elapsed_seconds=elapsed,
artifact_ids=agent_artifact_ids,
current_action=current_action or agent.current_task,
error=agent.output if agent.status == SwarmAgentStatus.FAILED else None,
summary=agent.output if agent.status != SwarmAgentStatus.FAILED else None,
)
)
phase_status = "completed"
if any(agent.status == SwarmAgentStatus.FAILED for agent in agents):
phase_status = "failed"
elif any(agent.status in {SwarmAgentStatus.PENDING, SwarmAgentStatus.RUNNING} for agent in agents):
phase_status = "running"
return [
SwarmPhaseInfo(
name=swarm.phase or "development",
status=phase_status,
agents=agent_phase_infos,
artifact_ids=artifact_ids,
summary=f"Runtime phase {swarm.phase or 'development'}",
)
]
def _synthesized_artifacts_for_swarm(db: Session, swarm: Swarm) -> list[Dict[str, Any]]:
"""Return stored artifacts or a compatibility summary for old empty terminal runs."""
if swarm.artifacts:
@@ -108,6 +290,9 @@ def _synthesized_artifacts_for_swarm(db: Session, swarm: Swarm) -> list[Dict[str
"source": "agent-manager-sub-mode-runtime",
"runtime_deployment_id": swarm.swarm_id,
"synthesized": True,
"summary_only": True,
"artifact_layout": "single_file_content",
"primary_read_path": "content",
"agent_count": len(agents),
"content_hash": stored.content_hash if stored else None,
"download_path": runtime_artifact_download_path(swarm.swarm_id, artifact_id),
@@ -119,13 +304,16 @@ def _synthesized_artifacts_for_swarm(db: Session, swarm: Swarm) -> list[Dict[str
def _build_swarm_status_response(db: Session, swarm: Swarm) -> SwarmStatusResponse:
"""Return a standard status payload for the sub-mode runtime compatibility API."""
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
display_status = project_runtime_run_status(swarm.status)
return SwarmStatusResponse(
deployment_id=swarm.swarm_id,
swarm_id=swarm.swarm_id,
status=swarm.status.value,
mode="sub_agile",
status=display_status,
phase=swarm.phase,
progress=swarm.progress,
agents=_agent_infos_for_swarm(db, swarm.swarm_id),
phases=_phases_for_swarm(db, swarm),
metrics=SwarmMetrics(
total_messages=swarm.total_messages,
tokens_used=swarm.tokens_used,
@@ -139,10 +327,10 @@ def _build_swarm_status_response(db: Session, swarm: Swarm) -> SwarmStatusRespon
def _stop_swarm_record(db: Session, swarm_id: str, request: SwarmStopRequest) -> SwarmStopResponse:
"""Idempotently stop a swarm database record."""
"""Idempotently stop a compatibility runtime-run database record."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
stopped_at = datetime.utcnow()
if swarm.status != SwarmStatus.STOPPED:
@@ -157,7 +345,7 @@ def _stop_swarm_record(db: Session, swarm_id: str, request: SwarmStopRequest) ->
return SwarmStopResponse(
deployment_id=swarm_id,
swarm_id=swarm_id,
status=SwarmStatus.STOPPED.value,
status=RuntimeDisplayStatus.STOPPED.value,
stopped_at=stopped_at,
)
@@ -184,7 +372,7 @@ async def initialize_and_execute_swarm(swarm_id: str, db_url: str):
await orchestrator.execute()
except Exception as e:
print(f"Error in background swarm execution: {e}")
print(f"Error in background sub-mode runtime execution: {e}")
finally:
await orchestrator.cleanup()
db.close()
@@ -261,7 +449,8 @@ async def create_swarm(
return SwarmCreateResponse(
deployment_id=swarm_id,
swarm_id=swarm_id,
status=swarm.status.value,
mode="sub_agile",
status=project_runtime_run_status(swarm.status),
agents=agent_infos,
created_at=swarm.created_at,
estimated_ready_at=swarm.created_at + timedelta(minutes=2)
@@ -316,6 +505,7 @@ async def create_swarm_compat(
project_context = plan.get("project_context") or {}
if not isinstance(project_context, dict):
project_context = {}
git_project_context = _extract_git_project_context(plan, payload)
project_context = {
**project_context,
"intent_id": plan.get("intent_id"),
@@ -334,6 +524,7 @@ async def create_swarm_compat(
"heicode_deployment_id": payload.get("deployment_id")
or metadata.get("heicode_deployment_id")
or metadata.get("manager_deployment_id"),
**git_project_context,
}
swarm_request = SwarmCreateRequest(
@@ -353,19 +544,19 @@ async def create_swarm_compat(
@swarms_router.get("/{swarm_id}", response_model=SwarmStatusResponse)
async def get_swarm_detail_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Compatibility detail endpoint for Manager Runtime bridge."""
"""Compatibility detail endpoint for legacy Manager runtime clients."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
return _build_swarm_status_response(db, swarm)
@swarms_router.get("/{swarm_id}/status", response_model=SwarmStatusResponse)
async def get_swarm_status_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Compatibility status endpoint for Manager Runtime bridge."""
"""Compatibility status endpoint for legacy Manager runtime clients."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
return _build_swarm_status_response(db, swarm)
@@ -383,7 +574,7 @@ async def get_swarm_logs(swarm_id: str, db: Session = Depends(get_db)):
"""Get aggregated logs from all agents in a sub-mode runtime run."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
agents = db.query(SwarmAgent).filter(SwarmAgent.swarm_id == swarm_id).all()
@@ -416,10 +607,19 @@ async def get_swarm_logs(swarm_id: str, db: Session = Depends(get_db)):
if agent.output:
log_lines.append(f"last_output={agent.output[:500]}")
if agent_messages:
log_lines.extend(
f"{message.created_at.isoformat()} {message.message_type}: {message.content[:300]}"
for message in agent_messages[-5:]
)
for message in agent_messages[-5:]:
metadata = message.message_metadata or {}
request_id = metadata.get("newapi_request_id")
usage = metadata.get("model_usage") or {}
suffix_parts = []
if request_id:
suffix_parts.append(f"newapi_request_id={request_id}")
if usage.get("total_tokens"):
suffix_parts.append(f"tokens={usage['total_tokens']}")
suffix = f" ({', '.join(suffix_parts)})" if suffix_parts else ""
log_lines.append(
f"{message.created_at.isoformat()} {message.message_type}: {message.content[:300]}{suffix}"
)
else:
log_lines.append("no_runtime_messages_recorded")
@@ -429,6 +629,17 @@ async def get_swarm_logs(swarm_id: str, db: Session = Depends(get_db)):
"namespace": agent.namespace,
"pod_name": agent.pod_name,
"logs": "\n".join(log_lines),
"messages": [
{
"message_id": message.message_id,
"message_type": message.message_type,
"created_at": message.created_at,
"newapi_request_id": (message.message_metadata or {}).get("newapi_request_id"),
"model_usage": (message.message_metadata or {}).get("model_usage"),
"metadata": message.message_metadata or {},
}
for message in agent_messages[-20:]
],
})
return logs
@@ -442,10 +653,10 @@ async def get_swarm_logs_compat(swarm_id: str, db: Session = Depends(get_db)):
@swarms_router.get("/{swarm_id}/events")
async def get_swarm_events_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Return swarm messages as Runtime events for Manager polling fallback."""
"""Return runtime messages as compatibility events for polling fallback."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
messages = (
db.query(SwarmMessage)
@@ -456,7 +667,7 @@ async def get_swarm_events_compat(swarm_id: str, db: Session = Depends(get_db)):
events = [
{
"event_id": message.message_id,
"event_type": f"swarm.message.{message.message_type}",
"event_type": f"runtime.message.{message.message_type}",
"swarm_id": swarm_id,
"agent_instance_id": message.from_agent_id or message.to_agent_id,
"occurred_at": message.created_at,
@@ -487,16 +698,16 @@ async def get_swarm_events_compat(swarm_id: str, db: Session = Depends(get_db)):
@swarms_router.get("/{swarm_id}/metrics")
async def get_swarm_metrics_compat(swarm_id: str, db: Session = Depends(get_db)):
"""Return basic Runtime metrics for Manager polling fallback."""
"""Return basic runtime metrics for compatibility polling fallback."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
elapsed_seconds = int((datetime.utcnow() - swarm.created_at).total_seconds())
return {
"success": True,
"swarm_id": swarm_id,
"status": swarm.status.value,
"status": project_runtime_run_status(swarm.status),
"stage": swarm.phase,
"checkpoint": "completed" if swarm.status == SwarmStatus.COMPLETED else "agent_running",
"metrics": {
@@ -514,10 +725,10 @@ async def get_swarm_artifact_content(
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return full persisted content for a Runtime artifact URI."""
"""Return full persisted content for a sub-mode runtime artifact URI."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
artifacts_by_id = {
artifact.get("artifact_id"): artifact
@@ -527,6 +738,14 @@ async def get_swarm_artifact_content(
if not artifact:
raise HTTPException(status_code=404, detail="Artifact not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if project_artifact:
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
stored = load_runtime_artifact(swarm_id, artifact_id)
if stored:
return FileResponse(
@@ -547,6 +766,183 @@ async def get_swarm_artifact_content(
raise HTTPException(status_code=404, detail="Artifact content not found")
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/manifest")
async def get_swarm_artifact_manifest(
swarm_id: str,
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return manifest JSON for a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if not project_artifact:
raise HTTPException(status_code=404, detail="Project artifact manifest not found")
return FileResponse(
path=project_artifact.manifest_path,
media_type="application/json",
filename=project_artifact.manifest_path.name,
)
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/archive.zip")
async def get_swarm_artifact_archive(
swarm_id: str,
artifact_id: str,
db: Session = Depends(get_db),
):
"""Return zip archive for a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
project_artifact = load_runtime_project_artifact(swarm_id, artifact_id)
if not project_artifact:
raise HTTPException(status_code=404, detail="Project artifact archive not found")
return FileResponse(
path=project_artifact.archive_path,
media_type="application/zip",
filename=project_artifact.archive_path.name,
)
@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/files/{file_path:path}")
async def get_swarm_artifact_file(
swarm_id: str,
artifact_id: str,
file_path: str,
db: Session = Depends(get_db),
):
"""Return a single file from a project-folder artifact."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
resolved_file = load_runtime_project_file(swarm_id, artifact_id, file_path)
if not resolved_file:
raise HTTPException(status_code=404, detail="Project artifact file not found")
return FileResponse(
path=resolved_file,
media_type=mimetypes.guess_type(str(resolved_file))[0] or "text/plain",
filename=resolved_file.name,
)
async def _emit_artifact_edit_event(swarm: Swarm, request: ArtifactEditRequest, event_type: str) -> None:
"""Emit a best-effort callback for project revision updates."""
project_context = swarm.project_context or {}
callback_config = project_context.get("_callback")
if not callback_config:
return
callback = CallbackDeliveryClient(callback_config)
if not callback.enabled:
return
payload = {
"artifact_id": request.artifact_id,
"project_revision": request.project_revision,
"base_project_revision": request.base_project_revision,
"base_content_hash": request.base_content_hash,
"manifest_uri": request.manifest_uri,
"archive_uri": request.archive_uri,
"source": request.source,
"runtime_deployment_id": swarm.swarm_id,
}
await callback.emit(
event_type,
project_context.get("manager_deployment_id")
or project_context.get("heicode_deployment_id")
or swarm.swarm_id,
swarm_id=swarm.swarm_id,
correlation_id=project_context.get("correlation_id"),
payload=payload,
)
def apply_runtime_artifact_edit(db: Session, swarm_id: str, request: ArtifactEditRequest) -> ArtifactEditResponse:
"""Persist an accepted project revision forwarded by Manager."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Runtime deployment not found")
artifacts = deepcopy(list(swarm.artifacts or []))
artifact = next((artifact for artifact in artifacts if artifact.get("artifact_id") == request.artifact_id), None)
if not artifact:
raise HTTPException(status_code=404, detail="ARTIFACT_REVISION_NOT_FOUND")
metadata = artifact.get("metadata") or {}
if metadata.get("artifact_layout") != "project_folder":
raise HTTPException(status_code=422, detail="ARTIFACT_EDIT_UNSUPPORTED_FOR_LAYOUT")
current_revision = int(metadata.get("project_revision") or 1)
if request.base_project_revision is not None and request.base_project_revision != current_revision:
raise HTTPException(status_code=409, detail="ARTIFACT_REVISION_CONFLICT")
metadata.update(
{
"project_revision": request.project_revision,
"base_project_revision": request.base_project_revision,
"base_content_hash": request.base_content_hash,
"manifest_uri": request.manifest_uri,
"archive_uri": request.archive_uri,
"content_hash": request.base_content_hash or metadata.get("content_hash"),
"revision_source": request.source,
"delivery_ref": {
"kind": "runtime_artifact",
"artifact_id": request.artifact_id,
"project_revision": request.project_revision,
},
}
)
artifact["metadata"] = metadata
swarm.artifacts = artifacts
flag_modified(swarm, "artifacts")
swarm.updated_at = datetime.utcnow()
db.commit()
message = SwarmMessage(
message_id=f"rev_{uuid.uuid4().hex[:12]}",
swarm_id=swarm_id,
from_agent_id=None,
to_agent_id=None,
message_type="artifact_edit",
content=f"artifact {request.artifact_id} revision {request.project_revision}",
message_metadata=request.model_dump(),
)
db.add(message)
db.commit()
return ArtifactEditResponse(
deployment_id=swarm_id,
artifact_id=request.artifact_id,
project_revision=request.project_revision,
status="accepted",
)
@swarms_router.post("/{swarm_id}/artifact-edits", response_model=ArtifactEditResponse)
async def receive_swarm_artifact_edit(
swarm_id: str,
request: ArtifactEditRequest,
db: Session = Depends(get_db),
):
"""Accept an accepted project revision forwarded by Manager."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
try:
response = apply_runtime_artifact_edit(db, swarm_id, request)
except HTTPException as exc:
if swarm and exc.status_code == 409:
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_conflict")
raise
if swarm:
await _emit_artifact_edit_event(swarm, request, "artifact.local_edit_applied")
return response
@swarms_router.post("/{swarm_id}/approvals/{approval_id}")
async def receive_swarm_approval_decision(
swarm_id: str,
@@ -554,10 +950,10 @@ async def receive_swarm_approval_decision(
request: ApprovalDecisionRequest,
db: Session = Depends(get_db)
):
"""Accept Manager approval decisions for paused high-risk swarm actions."""
"""Accept Manager approval decisions for paused high-risk runtime actions."""
swarm = db.query(Swarm).filter(Swarm.swarm_id == swarm_id).first()
if not swarm:
raise HTTPException(status_code=404, detail="Swarm not found")
raise HTTPException(status_code=404, detail="Runtime deployment not found")
if request.approval_id != approval_id:
raise HTTPException(status_code=422, detail="approval_id path/body mismatch")
if request.decision not in {"approved", "rejected"}:
+270 -11
View File
@@ -38,11 +38,15 @@ app.include_router(tool_generator_router)
# 注册外部工具 API Router(符合 MCP-Server 规范)
app.include_router(external_tool_router)
# 注册 Heicode Agnet API Router
# 注册 Heicode sub-mode Runtime 主 API Router
from api.agent.router import router as agent_router
app.include_router(agent_router)
# 注册 Heicode 兼容 API Router(旧 agnet 命名)
from api.agnet.router import router as agnet_router
app.include_router(agnet_router)
# 注册 Heicode sub-mode Runtime 兼容 Router
# 注册 Heicode sub-mode Runtime 兼容 Router(旧 /api/swarms 入口)
from api.swarm.router import swarms_router
app.include_router(swarms_router)
@@ -135,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
@@ -310,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
@@ -382,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():
"""健康检查"""
@@ -491,19 +608,35 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db
config_data=config_data
)
# 步骤3: 获取服务端口(从数据库动态获取)
service_port = template_manager.get_port(request.template)
# 步骤3: 获取服务端口
# OpenClaw 使用 18789 端口(沙箱镜像会自动启用)
if request.template == "openclaw":
service_port = 18789
else:
# 其他模板从数据库动态获取
service_port = template_manager.get_port(request.template)
# 步骤4: 创建 LoadBalancer Service(AKS 会自动分配外网 IP)
# 步骤4: 创建 Service(OpenClaw 已在 create_openclaw_deployment 中自动创建 Service 和 Ingress)
service_info = None
dns_info = None
if service_port:
# OpenClaw 使用 Ingress + HTTPS,不需要 LoadBalancer Service
if request.template == "openclaw":
logger.info("OpenClaw 使用 Ingress + HTTPS,跳过 LoadBalancer Service 创建")
# Service 和 Ingress 已在 create_openclaw_deployment 中自动创建
# 从 result 中获取访问信息
if "access_info" in result:
service_info = {
"name": f"{request.name}-service",
"type": "ClusterIP",
"note": "OpenClaw 使用 Ingress + HTTPS 访问"
}
elif service_port:
try:
import time
time.sleep(2) # 等待Pod启动
# 创建 LoadBalancer Service
# 创建 LoadBalancer Service(非 OpenClaw 模板)
service_info = temp_manager.create_service(
service_name=f"{request.name}-service",
namespace=agent_namespace,
@@ -567,11 +700,17 @@ async def create_agent(request: CreateAgentRequest, db: Session = Depends(get_db
"labels": pod.metadata.labels
}
# 添加 LoadBalancer Service 信息到响应
if service_info:
# 添加 Service 信息到响应
# OpenClaw 的访问信息已在 create_openclaw_deployment 中设置
if request.template == "openclaw":
# OpenClaw 使用 Ingress + HTTPS,访问信息已在 result 中
if "access_info" in result and result["access_info"].get("https_url"):
logger.info(f" - HTTPS 访问: {result['access_info']['https_url']}")
logger.info(f" - 注意: 使用自签名证书,浏览器会显示安全警告")
elif service_info:
result["service_info"] = service_info
# 构建访问信息
# 构建访问信息(非 OpenClaw 模板)
external_ip = service_info.get("external_ip")
if external_ip:
@@ -668,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)
@@ -684,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)):
"""
@@ -701,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('-')
@@ -740,6 +972,34 @@ async def delete_agent(agent_name: str, db: Session = Depends(get_db)):
except Exception as e:
logger.warning(f"删除 DNS 记录失败(可忽略): {str(e)}")
# 检查是否是 OpenClaw 类型的 Agent
is_openclaw = False
if db_agent and db_agent.agent_framework:
# 从数据库记录判断
pass
# 通过命名空间中的资源判断是否为 OpenClaw
try:
computed_ns = f"agent-{agent_name}".lower().strip('-')[:63]
temp_manager = K8sManager(namespace=computed_ns, kubeconfig_path=KUBECONFIG_PATH)
# 尝试查找 OpenClaw 特有的资源
try:
temp_manager.v1.read_namespaced_config_map(name=f"{agent_name}-config", namespace=computed_ns)
is_openclaw = True
logger.info(f"检测到 OpenClaw Agent: {agent_name}")
except:
pass
except:
pass
# 如果是 OpenClaw,先清理其特有资源
if is_openclaw:
try:
logger.info(f"清理 OpenClaw 专用资源...")
temp_manager.delete_openclaw_deployment(agent_name)
except Exception as e:
logger.warning(f"清理 OpenClaw 资源失败(可忽略): {e}")
# 步骤2: 删除 Agent 的独立命名空间(会自动删除Pod、Service等所有资源)
result = k8s_manager.delete_agent_namespace(agent_name=agent_name)
# region agent log
@@ -768,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()
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
##############################################################################
# Agent Manager - 快速构建和部署脚本 (ARM64)
# 用途: 一键构建 ARM64 Docker 镜像并部署到 AKS
##############################################################################
set -e
# 颜色输出
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# 配置变量
ACR_NAME="agnettaiji"
IMAGE_NAME="agent-manager"
IMAGE_TAG="latest-arm64"
FULL_IMAGE_NAME="${ACR_NAME}.azurecr.io/${IMAGE_NAME}:${IMAGE_TAG}"
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Agent Manager ARM64 构建和部署${NC}"
echo -e "${BLUE}========================================${NC}"
# 检查必要工具
echo -e "\n${GREEN}检查工具...${NC}"
command -v docker >/dev/null 2>&1 || { echo "需要安装 Docker"; exit 1; }
command -v kubectl >/dev/null 2>&1 || { echo "需要安装 kubectl"; exit 1; }
command -v az >/dev/null 2>&1 || { echo "需要安装 Azure CLI"; exit 1; }
# 登录 ACR
echo -e "\n${GREEN}登录 Azure Container Registry...${NC}"
az acr login --name ${ACR_NAME}
# 设置 buildx
echo -e "\n${GREEN}配置 Docker Buildx...${NC}"
BUILDER_NAME="arm64-builder"
if ! docker buildx inspect ${BUILDER_NAME} &> /dev/null; then
echo "创建 buildx builder: ${BUILDER_NAME}"
docker buildx create --name ${BUILDER_NAME} --use --driver docker-container
docker buildx inspect --bootstrap
else
docker buildx use ${BUILDER_NAME}
fi
# 构建并推送镜像
echo -e "\n${GREEN}构建 ARM64 镜像...${NC}"
docker buildx build \
--platform linux/arm64 \
-f Dockerfile \
-t ${FULL_IMAGE_NAME} \
--push \
.
echo -e "\n${GREEN}✅ 镜像构建完成: ${FULL_IMAGE_NAME}${NC}"
# 部署到 Kubernetes
echo -e "\n${GREEN}部署到 Kubernetes...${NC}"
echo -e "${YELLOW}使用脚本: scripts/deploy-to-k8s-arm64.sh --skip-build${NC}"
echo -e "${YELLOW}或手动运行: kubectl apply -f k8s/agent-manager-deployment.yaml${NC}"
# 询问是否立即部署
read -p "是否立即部署到 Kubernetes? [y/N] " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
if [ -f "scripts/deploy-to-k8s-arm64.sh" ]; then
bash scripts/deploy-to-k8s-arm64.sh --skip-build
else
echo -e "${YELLOW}部署脚本不存在,请手动部署:${NC}"
echo "kubectl apply -f k8s/agent-manager-deployment.yaml"
fi
fi
echo -e "\n${GREEN}========================================${NC}"
echo -e "${GREEN} 完成!${NC}"
echo -e "${GREEN}========================================${NC}"
+1 -1
View File
@@ -3,7 +3,7 @@ from enum import Enum
class ErrorCode(str, Enum):
"""Standard error codes for /api/agnet/* endpoints."""
"""Standard error codes for sub-mode runtime API endpoints."""
# Authentication
UNAUTHORIZED = "UNAUTHORIZED"
+3 -3
View File
@@ -17,11 +17,11 @@ class Settings(BaseSettings):
IDEMPOTENCY_TTL_SECONDS: int = 86400 # 24 hours
# Kubernetes
NAMESPACE_PREFIX: str = "agnet"
NAMESPACE_PREFIX: str = "agent"
# Model gateways
HEICODE_NEWAPI_BASE_URL: str = "https://code.xinghanlab.com"
LITELLM_BASE_URL: str = "http://litellm-service:8000"
HEICODE_NEWAPI_BASE_URL: str = "https://code.xinghanlab.com/v1"
LITELLM_BASE_URL: str = "https://code.xinghanlab.com/v1"
# Limits
MAX_PAYLOAD_SIZE_MB: int = 1
+6 -1
View File
@@ -17,7 +17,12 @@ import os
# Database URL - PostgreSQL (hardcoded)
DATABASE_URL = "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taijiagnet"
engine = create_engine(DATABASE_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_recycle=1800,
pool_use_lifo=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
+446
View File
@@ -0,0 +1,446 @@
# OpenClaw AKS 部署文件
# 包含沙箱功能 (DinD 模式)
#
# 部署步骤:
# 1. 先将 ACR 附加到 AKS: az aks update --name <aks> --resource-group <rg> --attach-acr openclawacr
# 2. 部署: kubectl apply -f openclaw-deploy.yaml
# 3. 查看状态: kubectl get pods -n openclaw
# 4. 查看日志: kubectl logs -n openclaw -l app=openclaw -c gateway -f
---
apiVersion: v1
kind: Namespace
metadata:
name: openclaw
---
# Secret: 存储敏感信息
apiVersion: v1
kind: Secret
metadata:
name: openclaw-secrets
namespace: openclaw
type: Opaque
stringData:
# Gateway 认证令牌
OPENCLAW_GATEWAY_TOKEN: "07f99167450cffe6c236a3da36ac3c029f708bee54d742820b523b990d3ba0d4"
# LiteLLM API Key
LITELLM_API_KEY: "sk-litellm-taiji-prod-8f3a9b2c4d5e6f7g"
# 飞书配置
FEISHU_APP_ID: "cli_a90af793703a9bcc"
FEISHU_APP_SECRET: "16Df1ByGy5frbdj7Vm5azbLOSBcprbaw"
# Telegram Bot Token
TELEGRAM_BOT_TOKEN: "8550418255:AAF50xr0MvNwZ4lqpW5PI5tZQ9uRZymKWvc"
# Gateway Auth Token (Web UI 用)
GATEWAY_AUTH_TOKEN: "6b8c483a5495fa1a0babe425504fcbf0633bb1ca8e8ed0fa0a89dfa30267636a"
---
# ConfigMap: OpenClaw 配置文件
apiVersion: v1
kind: ConfigMap
metadata:
name: openclaw-config
namespace: openclaw
data:
openclaw.json: |
{
"meta": {
"lastTouchedVersion": "2026.1.30"
},
"models": {
"providers": {
"litellm": {
"baseUrl": "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1",
"apiKey": "${LITELLM_API_KEY}",
"api": "openai-completions",
"models": [
{
"id": "taiji/gemini-2.5-flash",
"name": "Gemini 2.5 Flash",
"reasoning": false,
"input": ["text"],
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"contextWindow": 1000000,
"maxTokens": 8192
}
]
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "litellm/taiji/gemini-2.5-flash"
},
"models": {
"litellm/taiji/gemini-2.5-flash": {
"alias": "gemini-2.5-flash"
}
},
"workspace": "/home/node/.openclaw/workspace",
"compaction": { "mode": "safeguard" },
"maxConcurrent": 4,
"subagents": { "maxConcurrent": 8 },
"sandbox": {
"mode": "all",
"workspaceAccess": "rw",
"scope": "agent",
"docker": {
"image": "openclawacr.azurecr.io/openclaw-sandbox:arm64",
"network": "bridge"
},
"browser": {
"enabled": true,
"image": "openclawacr.azurecr.io/openclaw-sandbox-browser:arm64"
}
}
}
},
"messages": {
"ackReactionScope": "group-mentions"
},
"commands": {
"native": "auto",
"nativeSkills": "auto"
},
"channels": {
"telegram": {
"enabled": true,
"dmPolicy": "pairing",
"botToken": "${TELEGRAM_BOT_TOKEN}",
"groupPolicy": "allowlist",
"streamMode": "partial"
},
"feishu": {
"appId": "${FEISHU_APP_ID}",
"appSecret": "${FEISHU_APP_SECRET}",
"enabled": true,
"connectionMode": "websocket",
"dmPolicy": "open",
"groupPolicy": "open"
}
},
"gateway": {
"port": 18789,
"mode": "local",
"bind": "lan",
"auth": {
"mode": "token",
"token": "${GATEWAY_AUTH_TOKEN}"
},
"http": {
"endpoints": {
"chatCompletions": { "enabled": true }
}
}
},
"plugins": {
"entries": {
"telegram": { "enabled": true },
"feishu": { "enabled": true }
},
"installs": {
"feishu": {
"source": "npm",
"spec": "@m1heng-clawd/feishu",
"installPath": "/home/node/.openclaw/extensions/feishu",
"version": "0.1.6"
}
}
},
"tools": {
"sandbox": {
"tools": {
"allow": [
"exec", "process", "read", "write", "edit", "browser",
"sessions_list", "sessions_history", "sessions_send", "sessions_spawn", "session_status"
],
"deny": ["canvas", "nodes", "cron", "discord", "gateway"]
}
}
}
}
---
# PersistentVolumeClaim: 存储 workspace 和配置数据
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: openclaw-data
namespace: openclaw
spec:
accessModes:
- ReadWriteOnce
storageClassName: managed-csi # Azure AKS 默认存储类
resources:
requests:
storage: 10Gi
---
# Deployment: OpenClaw Gateway + DinD Sidecar
apiVersion: apps/v1
kind: Deployment
metadata:
name: openclaw
namespace: openclaw
labels:
app: openclaw
spec:
replicas: 1
selector:
matchLabels:
app: openclaw
template:
metadata:
labels:
app: openclaw
spec:
# 初始化容器:准备配置文件 + 安装飞书插件
initContainers:
# 第一步:使用 OpenClaw 镜像安装飞书插件
- name: plugin-install
image: openclawacr.azurecr.io/openclaw:arm64
command:
- sh
- -c
- |
echo "=== Installing Feishu plugin ==="
cd /home/node
# 创建必要目录
mkdir -p /data/extensions /data/workspace /data/sandboxes
# 安装飞书插件到持久化目录
if [ ! -d "/data/extensions/feishu/node_modules" ]; then
echo "Installing @m1heng-clawd/feishu plugin..."
npm pack @m1heng-clawd/feishu --pack-destination /tmp
mkdir -p /data/extensions/feishu
tar -xzf /tmp/m1heng-clawd-feishu-*.tgz -C /data/extensions/feishu --strip-components=1
cd /data/extensions/feishu && npm install --production
echo "Plugin installed successfully"
else
echo "Plugin already installed, skipping..."
fi
ls -la /data/extensions/feishu/
volumeMounts:
- name: data
mountPath: /data
# 第二步:准备配置文件(替换环境变量)
- name: config-init
image: busybox:1.36
command:
- sh
- -c
- |
# 复制配置文件并替换环境变量占位符
cp /config-template/openclaw.json /config/openclaw.json
sed -i "s|\${LITELLM_API_KEY}|$LITELLM_API_KEY|g" /config/openclaw.json
sed -i "s|\${FEISHU_APP_ID}|$FEISHU_APP_ID|g" /config/openclaw.json
sed -i "s|\${FEISHU_APP_SECRET}|$FEISHU_APP_SECRET|g" /config/openclaw.json
sed -i "s|\${TELEGRAM_BOT_TOKEN}|$TELEGRAM_BOT_TOKEN|g" /config/openclaw.json
sed -i "s|\${GATEWAY_AUTH_TOKEN}|$GATEWAY_AUTH_TOKEN|g" /config/openclaw.json
echo "Config initialized successfully"
cat /config/openclaw.json
env:
- name: LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: LITELLM_API_KEY
- name: FEISHU_APP_ID
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: FEISHU_APP_ID
- name: FEISHU_APP_SECRET
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: FEISHU_APP_SECRET
- name: TELEGRAM_BOT_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: TELEGRAM_BOT_TOKEN
- name: GATEWAY_AUTH_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: GATEWAY_AUTH_TOKEN
volumeMounts:
- name: config-template
mountPath: /config-template
- name: config
mountPath: /config
- name: data
mountPath: /data
containers:
# ========== Gateway 容器 ==========
- name: gateway
image: openclawacr.azurecr.io/openclaw:arm64
ports:
- containerPort: 18789
name: http
- containerPort: 18790
name: bridge
env:
- name: HOME
value: "/home/node"
- name: TERM
value: "xterm-256color"
- name: OPENCLAW_GATEWAY_TOKEN
valueFrom:
secretKeyRef:
name: openclaw-secrets
key: OPENCLAW_GATEWAY_TOKEN
# Docker 连接到 DinD sidecar
- name: DOCKER_HOST
value: "tcp://localhost:2375"
volumeMounts:
- name: config
mountPath: /home/node/.openclaw/openclaw.json
subPath: openclaw.json
- name: data
mountPath: /home/node/.openclaw/workspace
subPath: workspace
- name: data
mountPath: /home/node/.openclaw/sandboxes
subPath: sandboxes
- name: data
mountPath: /home/node/.openclaw/extensions
subPath: extensions
command:
- node
- dist/index.js
- gateway
- --bind
- lan
- --port
- "18789"
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
readinessProbe:
httpGet:
path: /health
port: 18789
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 18789
initialDelaySeconds: 30
periodSeconds: 30
# ========== DinD Sidecar (用于沙箱) ==========
- name: dind
image: docker:24-dind
securityContext:
privileged: true # DinD 需要特权模式
env:
- name: DOCKER_TLS_CERTDIR
value: "" # 禁用 TLS,内部通信
ports:
- containerPort: 2375
name: docker
volumeMounts:
- name: docker-storage
mountPath: /var/lib/docker
- name: data
mountPath: /home/node/.openclaw/workspace
subPath: workspace
- name: data
mountPath: /home/node/.openclaw/sandboxes
subPath: sandboxes
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "4Gi"
cpu: "2000m"
volumes:
- name: config-template
configMap:
name: openclaw-config
- name: config
emptyDir: {}
- name: data
persistentVolumeClaim:
claimName: openclaw-data
- name: docker-storage
emptyDir: {}
---
# Service: 暴露 Gateway 端口
apiVersion: v1
kind: Service
metadata:
name: openclaw
namespace: openclaw
spec:
selector:
app: openclaw
ports:
- name: http
port: 18789
targetPort: 18789
- name: bridge
port: 18790
targetPort: 18790
type: ClusterIP
---
# Ingress: 外部访问 (可选,需要 Ingress Controller)
# 如果使用 Azure Application Gateway 或 nginx-ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: openclaw
namespace: openclaw
annotations:
# 如果使用 nginx-ingress:
# kubernetes.io/ingress.class: nginx
# nginx.ingress.kubernetes.io/websocket-services: openclaw
# 如果使用 Azure Application Gateway:
# kubernetes.io/ingress.class: azure/application-gateway
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/websocket-services: "openclaw"
# 启用 HTTPS 重定向(可选,如果希望强制 HTTPS)
# nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
# TLS 配置:使用自签名证书
# 使用前需要先运行: ./generate-self-signed-cert.sh <your-domain> openclaw openclaw-tls
tls:
- hosts:
- openclaw.yourdomain.com # ← 修改为你的域名
secretName: openclaw-tls # ← 对应 Kubernetes Secret 名称
rules:
- host: openclaw.yourdomain.com # ← 修改为你的域名
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: openclaw
port:
number: 18789
+3 -2
View File
@@ -35,6 +35,7 @@ echo ""
echo "✅ Deployment complete!"
echo ""
echo "📝 Next steps:"
echo " 1. Port forward: kubectl port-forward -n agent-manager svc/agent-manager 8000:8000"
echo " 2. Test health: curl -H 'Authorization: Bearer heicode-prod-token-change-me' http://localhost:8000/api/agnet/health"
echo " 1. Port forward: kubectl port-forward -n agent-manager svc/agent-manager 8000:80"
echo " 2. Test health: curl http://localhost:8000/api/agent/health"
echo " 3. Compatibility health (legacy): curl http://localhost:8000/api/agnet/health"
echo ""
+408
View File
@@ -0,0 +1,408 @@
# Coding A2A Agent 创建与调用文档
本文档说明如何通过 `agent-manager` 创建 `coding_a2a_agent`,以及如何通过 A2A 协议调用它执行编程任务。
适用对象:
- 需要一个类似 Claude Code 的编程 agent
- 需要在启动时注入角色设定或团队约定
- 需要按需挂接 Git / MySQL / PostgreSQL / Azure Blob 资源
## 1. 模板定位
`coding_a2a_agent` 是一个:
- 以 `Pydantic AI` 为核心的编程 agent
- 对外暴露 `A2A` 协议
- 支持工作区代码工具
- 支持动态资源工具
主要能力:
- `read_file`
- `list_files`
- `write_file`
- `edit_file`
- `run_command`
- `git_*`
- `list_database_tables`
- `run_database_query`
- `list_blob_objects`
- `read_blob_text`
注意:
- 资源工具是否调用,由 agent 自己判断
- 某项资源没配置,不会阻止 agent 启动
- 未配置的资源工具被调用时会返回 `resource not configured`
## 2. 创建入口
通过 `agent-manager` 的旧版统一入口创建:
```http
POST /agents
```
请求体核心字段:
- `name`
- `template = "coding_a2a_agent"`
- `framework = "A2A"`
- `config.user_id`
- `env`
## 3. 最小创建示例
这是当前最小可工作的创建请求。
```json
{
"name": "coding-a2a-backend",
"template": "coding_a2a_agent",
"framework": "A2A",
"config": {
"user_id": "demo-user"
},
"env": {
"OPENAI_BASE_URL": "https://code.xinghanlab.com/v1",
"OPENAI_API_KEY": "sk-xxxx",
"MODEL_NAME": "gpt-5.4",
"AGENT_ACCESS_TOKEN": "550e8400-e29b-41d4-a716-446655440000",
"HEICODE_AGENT_ID": "dep-b5fab27e9255"
}
}
```
说明:
- `OPENAI_API_KEY` 当前建议在启动时传入
- 当前实测可用模型示例是 `gpt-5.4`
- 返回中会带 `namespace`、`pod_ip`、`access_info.external_ip`、`access_info.domain`
- 如果上层已注入 `AGENT_ACCESS_TOKEN`,A2A 请求入口会要求请求头 `X-Agent-Access-Token`
- 为兼容 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. 启动角色与团队约定
启动时可以通过环境变量注入角色和约束。
支持:
- `AGENT_ROLE_NAME`
- `AGENT_INSTRUCTION_TEXT`
- `AGENT_INSTRUCTION_FILE`
优先级:
1. `AGENT_INSTRUCTION_TEXT`
2. `AGENT_INSTRUCTION_FILE`
3. 默认通用系统提示词
示例:
```json
{
"name": "coding-a2a-backend",
"template": "coding_a2a_agent",
"framework": "A2A",
"config": {
"user_id": "demo-user"
},
"env": {
"OPENAI_BASE_URL": "https://code.xinghanlab.com/v1",
"OPENAI_API_KEY": "sk-xxxx",
"MODEL_NAME": "gpt-5.4",
"AGENT_ROLE_NAME": "backend",
"AGENT_INSTRUCTION_TEXT": "# Role\n你是 backend engineer\n\n# Constraints\n- 优先写 Python 代码\n- 不改 frontend\n- 修改后要自己做最小验证"
}
}
```
启动成功后可通过:
- `GET /health`
- `GET /.well-known/agent.json`
确认实例已经生效。
`/health` 会返回:
- `role_name`
- `instruction_source`
- `enabled_resources`
## 5. 动态资源工具
资源可以在启动时通过环境变量动态挂载,也可以在 A2A 请求中通过 `configuration.resources` 传入。
请求级配置会覆盖启动时环境变量配置。
### 5.1 Git
可选环境变量:
- `GIT_REPO_URL`
- `GIT_PROVIDER`
- `GIT_USERNAME`
- `GIT_PASSWORD`
- `GIT_TOKEN`
- `GIT_DEFAULT_BRANCH`
- `GIT_LOCAL_PATH`
- `GIT_ALLOWED_PATHS`
- `GIT_WRITE_MODE`
### 5.2 MySQL
至少需要:
- `MYSQL_HOST`
- `MYSQL_USER`
- `MYSQL_PASSWORD`
- `MYSQL_DATABASE`
可选:
- `MYSQL_PORT`
- `MYSQL_SSL_MODE`
### 5.3 PostgreSQL
至少需要:
- `POSTGRES_HOST`
- `POSTGRES_USER`
- `POSTGRES_PASSWORD`
- `POSTGRES_DATABASE`
可选:
- `POSTGRES_PORT`
- `POSTGRES_SSL_MODE`
兼容:
- `POSTGRESQL_HOST`
- `POSTGRESQL_USER`
- `POSTGRESQL_PASSWORD`
- `POSTGRESQL_DATABASE`
### 5.4 Azure Blob
至少需要:
- `AZURE_BLOB_CONTAINER`
再配下面任意一套:
1. `AZURE_BLOB_CONNECTION_STRING`
2. `AZURE_BLOB_ACCOUNT_URL` + `AZURE_BLOB_SAS_TOKEN`
3. `AZURE_BLOB_ACCOUNT_URL` + `AZURE_BLOB_ACCOUNT_KEY`
4. `AZURE_BLOB_ACCOUNT_NAME` + `AZURE_BLOB_ACCOUNT_KEY`
可选:
- `AZURE_BLOB_PREFIX`
兼容:
- `AZURE_STORAGE_CONNECTION_STRING`
- `AZURE_STORAGE_CONTAINER`
- `AZURE_STORAGE_ACCOUNT_NAME`
- `AZURE_STORAGE_ACCOUNT_KEY`
- `AZURE_STORAGE_PREFIX`
## 6. 健康检查与发现
实例创建完成后,推荐先检查:
```http
GET /health
GET /.well-known/agent.json
```
`/health` 示例响应:
```json
{
"status": "healthy",
"template_type": "coding_a2a_agent",
"role_name": "backend",
"instruction_source": "env_text",
"enabled_resources": ["git", "azure_blob"],
"auth_required": true,
"timestamp": "2026-06-04T05:04:22.760314Z"
}
```
## 7. A2A 调用方式
### 7.0 访问鉴权
当前模板 Agent 支持 HM 约定的本地访问鉴权:
- 如果实例环境变量里存在 `AGENT_ACCESS_TOKEN`,则 `POST /message/send`、`POST /message/stream`、`GET /tasks/{task_id}` 必须带请求头 `X-Agent-Access-Token`
- 服务端使用常量时间比较校验 `X-Agent-Access-Token == AGENT_ACCESS_TOKEN`
- 缺少请求头时返回 `401`
- 请求头不匹配时返回 `403`
- 如果实例没有注入 `AGENT_ACCESS_TOKEN`,则继续兼容放行
注意:
- `X-Agent-Access-Token` 负责“谁有权访问这个 agent”
- A2A body 里的 `api_key` 负责“本次请求用谁的模型额度”
- 两者职责分离,不互相替代
- 未绑定代码仓库时,runtime 会自动创建空的 `workspace.root_dir`,不再因为远端缺少 `/workspace` 而直接崩溃
- 绑定 git 资源时,runtime 会先自动准备工作区;如果 clone / branch / repo 本身失败,会返回结构化 JSON-RPC error,而不是直接冒成 uvicorn 500
### 7.1 同步调用
```http
POST /message/send
```
最小调用示例:
如果实例启用了访问鉴权,请附带请求头:
```http
X-Agent-Access-Token: 550e8400-e29b-41d4-a716-446655440000
```
```json
{
"jsonrpc": "2.0",
"id": "task-1",
"method": "message/send",
"params": {
"api_key": "sk-xxxx",
"model": "gpt-5.4",
"message": {
"role": "user",
"parts": [
{
"kind": "text",
"text": "请在 /workspace 下创建 math_tools.py,包含 factorial 和 is_prime,并自行做最小验证。"
}
]
},
"configuration": {
"workspace": {
"root_dir": "/workspace",
"allowed_paths": ["math_tools.py"]
}
}
}
}
```
### 7.2 流式调用
```http
POST /message/stream
```
返回为 `text/event-stream`。
如果实例开启了访问鉴权,流式调用同样需要带:
```http
X-Agent-Access-Token: 550e8400-e29b-41d4-a716-446655440000
```
## 8. 请求级资源覆盖示例
如果你不想在启动时固定资源,可以在具体任务里传:
```json
{
"jsonrpc": "2.0",
"id": "task-2",
"method": "message/send",
"params": {
"api_key": "sk-xxxx",
"model": "gpt-5.4",
"message": {
"role": "user",
"parts": [
{
"kind": "text",
"text": "读取 blob 中的文档摘要,并根据内容生成一个 Python 数据结构。"
}
]
},
"configuration": {
"workspace": {
"root_dir": "/workspace"
},
"resources": {
"azure_blob": {
"container_name": "artifacts",
"connection_string": "UseDevelopmentStorage=true"
}
}
}
}
}
```
## 9. 已验证行为
当前已做过真实线上验证:
- 启动时 `AGENT_ROLE_NAME` 生效
- 启动时 `AGENT_INSTRUCTION_TEXT` 生效
- `/health` 正确返回 `role_name` 和 `instruction_source`
- `message/send` 可真实调用模型
- agent 能在 `/workspace` 中:
- 新建 Python 文件
- 修改已有文件
- 创建子目录下的代码文件
- 执行最小验证命令
## 10. 当前注意事项
- 当前实例启动阶段建议提供 `OPENAI_API_KEY`
- 当前网关下不同 key 可用模型可能不同,示例里使用 `gpt-5.4`
- 如果 workspace 不是 git 仓库,agent 可能会尝试执行 `git status`,但这不会阻止大多数代码任务完成
- 如果某项资源没配置,agent 仍会启动,只是在调用对应资源工具时返回未配置提示
+18 -18
View File
@@ -1,4 +1,4 @@
# Agnet → Heicode Manager 反向 Callback 契约 v1(提案)
# Agent Runtime → Heicode Manager 反向 Callback 契约 v1(提案)
> **状态**:DRAFT — 由 Heicode Manager 团队起草,发回给 Agent Manager 团队评审
> **配套阅读**:`HEICODE_API_INTEGRATION.md`(v2.0.0,正向:Heicode → Agent Manager)
@@ -29,10 +29,10 @@
### 1.1 正向(已实现,文档 v2.0.0)
```
Heicode Manager ──POST /api/agnet/deployments──▶ Agent Manager
──GET /api/agnet/.../logs────▶
──GET /api/agnet/.../events──▶
──GET /api/agnet/.../metrics─▶
Heicode Manager ──POST /api/agent/sub-agile/deployments──▶ Agent Manager
──GET /api/agent/.../logs────▶
──GET /api/agent/.../events──▶
──GET /api/agent/.../metrics─▶
```
### 1.2 反向(**未定义** — 本文要解决的)
@@ -67,7 +67,7 @@ Heicode Manager ◀──??? Agent Manager 怎么告诉我们:
│ │ │ 审批等事件触发 │
│ │ │ │
│ ③ 接收回调 │ ◀──POST {callback_url}───│ │
│ /api/agnet │ 含 HMAC 签名 + │ │
│ /api/agent │ 含 HMAC 签名 + │ │
│ /callback │ X-Agnet-Event-Id 幂等 │ │
│ │ │ │
│ ④ 200 OK 回执 │ ────────────────────────▶│ │
@@ -78,18 +78,18 @@ Heicode Manager ◀──??? Agent Manager 怎么告诉我们:
---
## 3. Heicode 侧注册(callback_url 怎么告诉 Agnet)
## 3. Heicode 侧注册(callback_url 怎么告诉 Agent Runtime)
### 3.1 创建部署时携带
扩展 `POST /api/agnet/deployments` 请求体,新增可选字段:
扩展 `POST /api/agent/sub-agile/deployments` 请求体,新增可选字段:
```json
{
"orchestration_plan": "...",
"agents": [ ... ],
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key",
"subscribed_events": [
"phase.changed",
@@ -114,7 +114,7 @@ Heicode Manager ◀──??? Agent Manager 怎么告诉我们:
### 3.2 后绑定 / 修改(可选 P2 阶段)
```
PATCH /api/agnet/deployments/{deployment_id}/callback
PATCH /api/agent/sub-agile/deployments/{deployment_id}/callback
```
允许在部署运行期间更换 callback URL(例如 Heicode 灰度发布切换接收端)。
@@ -127,12 +127,12 @@ PATCH /api/agnet/deployments/{deployment_id}/callback
| 方法 | URL | 说明 |
|------|-----|------|
| `POST` | `{callback_url}` | Agnet 推送事件 |
| `POST` | `{callback_url}` | Agent Runtime 推送事件 |
Heicode 生产端点(建议):
```
POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
POST https://code.xinghanlab.com/api/agent/callbacks/runtime-events
```
### 4.2 必需 Headers
@@ -405,7 +405,7 @@ HTTP/1.1 400 Bad Request
**Heicode 拿到这个事件后做什么**:
1. 推到桌面客户端的审批 UI(已有契约)
2. 用户点同意 / 拒绝
3. Heicode 调正向接口:`POST /api/agnet/deployments/{id}/approvals/{approval_id}` body `{ "decision": "granted" | "rejected", "reason": "..." }`
3. Heicode 调正向接口:`POST /api/agent/sub-agile/deployments/{id}/approvals/{approval_id}` body `{ "decision": "granted" | "rejected", "reason": "..." }`
### 5.6 `approval.granted` / 5.7 `approval.rejected`
@@ -521,11 +521,11 @@ stopped / failed 时 `failure_code` / `failure_message` 必填。
为了让 Heicode 这边在没有真实部署的情况下也能联调 callback 接收逻辑:
```
POST /api/agnet/_mock/emit_event
POST /api/agent/_mock/emit_event
Authorization: Bearer <SERVICE_TOKEN>
{
"callback_url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"event_type": "phase.changed",
"deployment_id": "dep_mock_001",
"data": { "from_phase": null, "to_phase": "requirements" }
@@ -542,7 +542,7 @@ Authorization: Bearer <SERVICE_TOKEN>
```
agnet-cli mock-emit \
--target https://staging.heicode.local/api/agnet/callbacks \
--target https://staging.heicode.local/api/agent/callbacks \
--event sk_tool.called \
--deployment dep_mock_001 \
--signing-secret "$(cat /tmp/test-secret)"
@@ -578,7 +578,7 @@ agnet-cli mock-emit \
- Heicode 端校验 `X-Agnet-Timestamp` 落在 `now ± 5 分钟` 内
- 超出 → 返回 `400 SIGNATURE_INVALID`(防止回放)
- Agnet 端**必须**用 NTP 同步时钟,最大允许偏移 ±60 秒
- Agent Runtime 端**必须**用 NTP 同步时钟,最大允许偏移 ±60 秒
---
@@ -616,7 +616,7 @@ agnet-cli mock-emit \
- [ ] 实现 §6 mock-emit 接口
**Heicode Manager 侧**(不依赖 Agent Manager 完成):
- [ ] 实现 `/api/agnet/callbacks/swarm-events` 接收端
- [ ] 实现 `/api/agent/callbacks/runtime-events` 接收端
- [ ] 实现 §4.3 HMAC 校验、§4.4 幂等去重(复用 Redis SETNX,参考 V2 device-signature nonce 实现)
- [ ] 事件入审计表(复用 `agnet_audit_events`)
- [ ] 给桌面客户端 push 接口(已有 SSE 通道复用)
+187 -68
View File
@@ -23,7 +23,7 @@
- **当前 AKS 镜像 digest**: `sha256:b1931c1172fc23da8234e96dbdca34c4704644c2b2099391b362a48c47dc68f4`
- **Base URL(当前联调)**: `http://20.212.121.126`
- **Base URL(域名待切换)**: `https://agent-manager.taijiagnet.com`
- **主 API 前缀**: `/api/agnet`
- **主 API 前缀**: `/api/agent`
- **Runtime 兼容前缀**: `/api/swarms`
### 1.2 核心功能
@@ -31,6 +31,7 @@
- ✅ 多 Agent 编排部署
- ✅ Heicode sub 模式敏捷开发对接(agile / waterfall)
- ✅ `/api/swarms` Runtime 适配入口
- ✅ 模板 Agent `/agents` 生命周期兼容接口
- ✅ 预算控制和计费管理
- ✅ 风险等级评估(low/medium/high)
- ✅ Azure Key Vault `secret_ref` 引用
@@ -50,7 +51,7 @@
▼
┌─────────────────────────────┐
│ Agent Manager API │
│ /api/agnet/* │
│ /api/agent/* │
└──────┬──────────────────────┘
│
▼
@@ -69,7 +70,7 @@
| 系统 | 职责 | 说明 |
|------|------|------|
| Heicode Manager | 用户、资源绑定、模型网关配置、审批、部署草稿、权限清单、回调持久化、artifact/timeline 展示 | 已有本地控制面和生产页面 |
| Agent Manager / Agnet Runtime | 接收 Manager 传入的部署计划,真实创建/调度子 Agent,执行任务,按回调协议回写状态、产物、用量和审批请求 | 需要支持本文定义的请求与回调字段 |
| Agent Manager / Agent Runtime | 接收 Manager 传入的部署计划,真实创建/调度子 Agent,执行任务,按回调协议回写状态、产物、用量和审批请求 | 需要支持本文定义的请求与回调字段 |
| Azure Key Vault | 长期密钥托管 | Manager/Runtime 只能使用 `azkv://...` 引用,不能传明文密钥 |
| NewAPI / CodeGW | 模型网关与计费入口 | Runtime 使用 Manager 提供的模型、预算和 `secret_ref` 上下文 |
@@ -89,7 +90,7 @@
| Callback HMAC 验签 | 已支持 | 支持 `X-Agnet-Signature` / `X-Agnet-Timestamp` / `X-Agnet-Event-Id` |
| Callback 旧认证兼容 | 已支持 | 过渡期仍接受 `X-Agnet-Service-Token` 或 `Authorization: Bearer` |
| Callback 幂等 | 已支持 | 优先读 `X-Agnet-Event-Id`,兼容 body `event_id` |
| Runtime 主动回调 | 已支持 | `/api/agnet/deployments` 与 `/api/swarms` 创建的 Runtime 执行阶段会主动推送 status/phase/timeline/agent/tool/artifact 事件 |
| Runtime 主动回调 | 已支持 | `/api/agent/sub-agile/deployments` 与 `/api/swarms` 创建的 Runtime 执行阶段会主动推送 status/phase/timeline/agent/tool/artifact 事件 |
| 普通 sub 真实 artifact 回调 | 已支持 | 普通 sub agent 真正执行后会生成 `artifact.created`,不再只返回 completed |
| Runtime artifact 内容读取 | 已支持 | Runtime 会优先用 K8s Secret 中的 Azure Blob 凭据上传完整产物;失败时回落本地 artifact store,metadata 中返回 URI、`content_hash` 和下载路径 |
| 普通 sub task 终态回调 | 已支持 | 新增 `task.completed` / `task.failed` / `task.blocked` 事件 |
@@ -103,13 +104,14 @@
| 顶层 `resource_grants` | 已支持 | 兼容 `agents[].resource_grants` 汇总 |
| legacy ResourceGrant 字段 | 已支持 | 兼容 `type / permissions / ref` 与 `resource_type / permission_scope / secret_ref` |
| artifact/timeline/SK snapshot 查询 | 已支持 | 从 callback 事件投影到用户态查询接口 |
| `approval.requested` / decision | 已支持 | callback 会持久化审批请求;Runtime 接收 `/api/swarms/{swarm_id}/approvals/{approval_id}` 与 `/api/agnet/deployments/{deployment_id}/approvals/{approval_id}` decision |
| `approval.requested` / decision | 已支持 | callback 会持久化审批请求;Runtime 接收 `/api/swarms/{swarm_id}/approvals/{approval_id}` 与 `/api/agent/sub-agile/deployments/{deployment_id}/approvals/{approval_id}` decision |
| `/api/swarms` 运行期查询 | 已支持 | 兼容 `status`、`stop`、`logs`、`events`、`metrics` 查询/控制路径 |
| `/api/swarms` 创建校验 | 已支持 | 缺少 `orchestration_plan` / `callback.url` / `sub_mode` / `user_context.user_id` 返回 422;`dry_run:true` 返回 422 且不创建真实 run |
| `/api/swarms` 幂等 | 已支持 | 同一个 `X-Idempotency-Key` 返回已有 run,不重复创建 |
| 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 侧职责:
@@ -124,15 +126,16 @@
| 场景 | 推荐接口 | 当前状态 |
|------|----------|----------|
| 健康检查 | `GET /api/agnet/health` | 已支持,无需业务 Header |
| 健康检查 | `GET /api/agent/health` | 已支持,无需业务 Header |
| 普通 sub 创建 Runtime run | `POST /api/swarms` | 已支持,要求结构化 `orchestration_plan` 和 `callback.url` |
| 旧版 Agent 部署创建 | `POST /api/agnet/deployments` | 已支持,可兼容结构化 sub plan |
| Runtime 主动事件回写 | `POST /api/agnet/callbacks/swarm-events` | 已支持 HMAC / 旧 token 过渡认证和幂等 |
| 旧版 Agent 部署创建 | `POST /api/agent/sub-agile/deployments` | 已支持,可兼容结构化 sub plan |
| Runtime 主动事件回写 | `POST /api/agent/callbacks/runtime-events` | 已支持 HMAC / 旧 token 过渡认证和幂等 |
| 查询 Runtime 状态 | `GET /api/swarms/{swarm_id}` 或 `/status` | 已支持,`deployment_id` 与 `swarm_id` 当前同值 |
| 查询产物 | `GET /api/agnet/user/deployments/{deployment_id}/artifacts` | 已支持,由 callback event 投影 |
| 查询时间线 | `GET /api/agnet/user/deployments/{deployment_id}/timeline` | 已支持,由 callback event 合并 |
| 查询 SK snapshot | `GET /api/agnet/user/deployments/{deployment_id}/sk-snapshots` | 已支持投影查询,独立解析接口待增强 |
| 查询产物 | `GET /api/agent/user/deployments/{deployment_id}/artifacts` | 已支持,由 callback event 投影 |
| 查询时间线 | `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 联调 |
当前实现边界:
@@ -149,8 +152,8 @@
1. 创建 Runtime run 后保存返回的 `deployment_id` / `swarm_id`。当前实现里二者同值。
2. 通过 callback 里的 `artifact.created` 事件,或轮询 `GET /api/swarms/{swarm_id}/status` 判断是否已有 artifact。
3. 调用 `GET /api/agnet/user/deployments/{deployment_id}/artifacts` 获取产物列表。
4. 从列表中取 `artifact_id`,调用 `GET /api/agnet/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` 下载完整内容。
3. 调用 `GET /api/agent/user/deployments/{deployment_id}/artifacts` 获取产物列表。
4. 从列表中取 `artifact_id`,调用 `GET /api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` 下载完整内容。
5. 如果 Manager 需要直接访问 Runtime 兼容层,也可以调用 `GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content`。
示例:
@@ -162,7 +165,7 @@ DEPLOYMENT_ID="swm_xxx"
curl -sS \
-H "Authorization: Bearer ${TOKEN}" \
"${BASE_URL}/api/agnet/user/deployments/${DEPLOYMENT_ID}/artifacts"
"${BASE_URL}/api/agent/user/deployments/${DEPLOYMENT_ID}/artifacts"
```
列表响应中的关键字段:
@@ -198,7 +201,7 @@ ARTIFACT_ID="art_backend_patch_001"
curl -L \
-H "Authorization: Bearer ${TOKEN}" \
-o "${ARTIFACT_ID}.txt" \
"${BASE_URL}/api/agnet/user/deployments/${DEPLOYMENT_ID}/artifacts/${ARTIFACT_ID}/content"
"${BASE_URL}/api/agent/user/deployments/${DEPLOYMENT_ID}/artifacts/${ARTIFACT_ID}/content"
```
生产环境产物存储规则:
@@ -256,7 +259,7 @@ Authorization: Bearer <HEICODE_SERVICE_TOKEN>
| `X-Idempotency-Key` | ⚪ | 幂等性键(推荐) | `idem_abc123` |
| `Content-Type` | ✅ | 内容类型 | `application/json` |
> `GET /api/agnet/health` 用于 K8s / LB 探活,不要求 `Authorization` 或业务追踪 Header。
> `GET /api/agent/health` 用于 K8s / LB 探活,不要求 `Authorization` 或业务追踪 Header。
### 2.3 获取 Service Token
@@ -268,13 +271,13 @@ Authorization: Bearer <HEICODE_SERVICE_TOKEN>
### 3.1 健康检查
#### `GET /api/agnet/health`
#### `GET /api/agent/health`
检查服务状态。
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/health"
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/health"
```
**响应示例**:
@@ -294,7 +297,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/health"
### 3.2 创建部署
#### `POST /api/agnet/deployments`
#### `POST /api/agent/sub-agile/deployments`
创建一个新的 Agent 部署。
@@ -332,7 +335,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/health"
}
],
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key",
"subscribed_events": [
"phase.changed",
@@ -389,13 +392,13 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/health"
- `orchestration_plan.billing_context.default_model_id` / `allowed_model_ids` / `secret_ref` 会透传到 Runtime 配置。
- `resource_grants` 可放在顶层,也可放在 `agents[].resource_grants`,Runtime 会做兼容汇总。
- 如果请求包含 `callback`,Runtime 会按订阅事件主动回调 `deployment.status_changed`、`phase.changed`、`timeline.updated`、`agent.started`、`artifact.created`,并在需要审批时回调 `approval.requested`。
- `callback.url` 在 `/api/agnet/deployments` 中必须为 `https://`,`callback.signing_secret_ref` 必须为 `azkv://`。
- `callback.url` 在 `/api/agent/sub-agile/deployments` 中必须为 `https://`,`callback.signing_secret_ref` 必须为 `azkv://`。
---
### 3.3 列出部署
#### `GET /api/agnet/deployments`
#### `GET /api/agent/sub-agile/deployments`
获取部署列表,支持过滤和分页。
@@ -410,7 +413,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/health"
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments?user_id=user_123&status=running&limit=10" \
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments?user_id=user_123&status=running&limit=10" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -445,7 +448,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments?user_id=
### 3.4 获取部署详情
#### `GET /api/agnet/deployments/{deployment_id}`
#### `GET /api/agent/sub-agile/deployments/{deployment_id}`
获取指定部署的详细信息。
@@ -454,7 +457,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments?user_id=
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6" \
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments/dep_a1b2c3d4e5f6" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -510,7 +513,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
### 3.5 停止部署
#### `POST /api/agnet/deployments/{deployment_id}/stop`
#### `POST /api/agent/sub-agile/deployments/{deployment_id}/stop`
停止一个正在运行的部署。
@@ -527,7 +530,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
**请求示例**:
```bash
curl -X POST "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/stop" \
curl -X POST "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments/dep_a1b2c3d4e5f6/stop" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -551,7 +554,7 @@ curl -X POST "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b
### 3.6 获取部署日志
#### `GET /api/agnet/deployments/{deployment_id}/logs`
#### `GET /api/agent/sub-agile/deployments/{deployment_id}/logs`
获取部署的实时日志。
@@ -567,7 +570,7 @@ curl -X POST "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/logs?limit=50" \
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments/dep_a1b2c3d4e5f6/logs?limit=50" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -604,7 +607,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
### 3.7 获取部署事件
#### `GET /api/agnet/deployments/{deployment_id}/events`
#### `GET /api/agent/sub-agile/deployments/{deployment_id}/events`
获取部署的事件历史。
@@ -629,7 +632,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/events" \
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments/dep_a1b2c3d4e5f6/events" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -670,7 +673,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
### 3.8 获取资源指标
#### `GET /api/agnet/deployments/{deployment_id}/metrics`
#### `GET /api/agent/sub-agile/deployments/{deployment_id}/metrics`
获取部署的资源使用指标。
@@ -679,7 +682,7 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
**请求示例**:
```bash
curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/metrics" \
curl -X GET "https://agent-manager.taijiagnet.com/api/agent/sub-agile/deployments/dep_a1b2c3d4e5f6/metrics" \
-H "Authorization: Bearer sk_xxx" \
-H "X-User-ID: user_123" \
-H "X-Binding-Scope: workspace_abc" \
@@ -730,11 +733,11 @@ curl -X GET "https://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2
### 3.9 Runtime Callback 回写
#### `POST /api/agnet/callbacks/swarm-events`
#### `POST /api/agent/callbacks/runtime-events`
Agent Manager / Runtime 使用该接口向 Heicode Manager 回写 sub 模式事件、阶段变化、产物、预算告警、审批请求和 SK 工具调用结果。该接口是反向通知协议,不能仅依赖 `/events` 轮询替代。
#### `GET /api/agnet/callbacks/swarm-events/schema`
#### `GET /api/agent/callbacks/runtime-events/schema`
联调前可读取 callback schema。该接口只返回事件类型、分类、必填字段、阶段枚举和 artifact 类型,不返回 token、secret 或任何明文密钥。
@@ -826,7 +829,7 @@ Runtime 发送端签名密钥解析顺序:
| 触发时机 | 事件 |
|----------|------|
| `/api/agnet/deployments` 创建 accepted/running | `deployment.status_changed`、`phase.changed`、`timeline.updated`、`agent.started`、`artifact.created` |
| `/api/agent/sub-agile/deployments` 创建 accepted/running | `deployment.status_changed`、`phase.changed`、`timeline.updated`、`agent.started`、`artifact.created` |
| Swarm 初始化 / 运行 / 完成 / 失败 / 停止 | `deployment.status_changed` |
| 规划、实现、检查、完成等阶段变化 | `phase.changed`、`timeline.updated` |
| Agent 可运行 | `agent.started` |
@@ -879,22 +882,22 @@ Runtime 发送端签名密钥解析顺序:
| 方法 | 路径 | 调用方 | 用途 |
|------|------|--------|------|
| `POST` | `/api/agnet/user/tasks/{task_id}/deployment-draft` | Heicode 客户端 / Manager 前端 | 从任务卡生成 Agnet deployment draft |
| `POST` | `/api/agnet/user/deployments` | Heicode 客户端 / Manager 前端 | 用户态创建部署记录 |
| `GET` | `/api/agnet/user/deployments` | Heicode 客户端 / Manager 前端 | 用户态部署列表 |
| `GET` | `/api/agnet/user/deployments/{deployment_id}` | Heicode 客户端 / Manager 前端 | 用户态部署详情 |
| `POST` | `/api/agent/user/tasks/{task_id}/deployment-draft` | Heicode 客户端 / Manager 前端 | 从任务卡生成 Agent deployment draft |
| `POST` | `/api/agent/user/deployments` | Heicode 客户端 / Manager 前端 | 用户态创建部署记录 |
| `GET` | `/api/agent/user/deployments` | Heicode 客户端 / Manager 前端 | 用户态部署列表 |
| `GET` | `/api/agent/user/deployments/{deployment_id}` | Heicode 客户端 / Manager 前端 | 用户态部署详情 |
| `POST` | `/api/swarms` | Runtime 对接适配 / Manager | 创建 Swarm Run 的兼容入口,目前映射到 Manager 本地部署控制面 |
| `POST` | `/api/agnet/callbacks/swarm-events` | Agent Manager / Runtime | Runtime 回写状态、事件、artifact |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/artifacts` | Heicode 客户端 / Manager 前端 | 查询部署产物 |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` | Heicode 客户端 / Manager 前端 | 下载完整产物内容 |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/sk-snapshots` | Heicode 客户端 / Manager 前端 | 查询 SK 快照 |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/timeline` | Heicode 客户端 / Manager 前端 | 查询合并时间线 |
| `POST` | `/api/agent/callbacks/runtime-events` | Agent Manager / Runtime | Runtime 回写状态、事件、artifact |
| `GET` | `/api/agent/user/deployments/{deployment_id}/artifacts` | Heicode 客户端 / Manager 前端 | 查询部署产物 |
| `GET` | `/api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` | Heicode 客户端 / Manager 前端 | 下载完整产物内容 |
| `GET` | `/api/agent/user/deployments/{deployment_id}/sk-snapshots` | Heicode 客户端 / Manager 前端 | 查询 SK 快照 |
| `GET` | `/api/agent/user/deployments/{deployment_id}/timeline` | Heicode 客户端 / Manager 前端 | 查询合并时间线 |
说明:
1. `POST /api/swarms` 当前返回 `deployment_id` 和 `swarm_id`;当前二者同值,均可用于 Runtime 查询和停止。
2. 后续如果 Runtime 返回自己的真实 `swarm_id`,Manager 需要保存 `deployment_id <-> swarm_id` 映射。
3. Runtime 侧不能只支持 `/api/agnet/deployments`,否则无法覆盖 Heicode 用户态任务流。
3. Runtime 侧不能只支持 `/api/agent/sub-agile/deployments`,否则无法覆盖 Heicode 用户态任务流。
#### `POST /api/swarms`
@@ -951,7 +954,7 @@ Heicode sub 模式兼容入口。该接口接受结构化 `orchestration_plan`
"resource_grants": []
},
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key"
}
}
@@ -980,7 +983,7 @@ Heicode sub 模式兼容入口。该接口接受结构化 `orchestration_plan`
}
},
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key"
}
}
@@ -1039,7 +1042,7 @@ Heicode sub 模式兼容入口。该接口接受结构化 `orchestration_plan`
如果普通 sub 不走 `/api/swarms`,也支持:
```http
POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id}
POST /api/agent/sub-agile/deployments/{deployment_id}/approvals/{approval_id}
```
**响应示例**:
@@ -1087,7 +1090,7 @@ POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id}
}
```
#### `GET /api/agnet/user/deployments/{deployment_id}/artifacts`
#### `GET /api/agent/user/deployments/{deployment_id}/artifacts`
查询 Runtime 通过 `artifact.created` callback 回写的产物。当前 Manager 从 callback event payload 投影生成响应;大文件只返回 `uri`、摘要、大小和 hash 信息,完整内容需要继续调用 artifact content 接口读取。完整操作流程见 [1.7 产物获取速查](#17-产物获取速查)。
@@ -1119,7 +1122,7 @@ POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id}
}
```
#### `GET /api/agnet/user/deployments/{deployment_id}/artifacts/{artifact_id}/content`
#### `GET /api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content`
读取 Runtime artifact 的完整内容。该接口是 Manager / 前端获取产物正文的推荐入口,要求携带 `Authorization: Bearer <HEICODE_SERVICE_TOKEN>`。
@@ -1138,7 +1141,7 @@ Azure Blob 凭据来自 `RUNTIME_ARTIFACT_BLOB_SECRET_NAMESPACE` / `RUNTIME_ARTI
curl -L \
-H "Authorization: Bearer <HEICODE_SERVICE_TOKEN>" \
-o artifact-output.txt \
"https://agent-manager.taijiagnet.com/api/agnet/user/deployments/{deployment_id}/artifacts/{artifact_id}/content"
"https://agent-manager.taijiagnet.com/api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content"
```
如果调用方已经持有 Runtime `swarm_id`,也可以直接使用兼容接口:
@@ -1150,7 +1153,7 @@ curl -L \
"https://agent-manager.taijiagnet.com/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"
```
#### `GET /api/agnet/user/deployments/{deployment_id}/timeline`
#### `GET /api/agent/user/deployments/{deployment_id}/timeline`
查询合并时间线。当前 Manager 会合并 `timeline.updated`、阶段变化、Agent 状态、预算告警、审批请求、artifact 与 SK 工具事件。
@@ -1180,7 +1183,7 @@ curl -L \
}
```
#### `GET /api/agnet/user/deployments/{deployment_id}/sk-snapshots`
#### `GET /api/agent/user/deployments/{deployment_id}/sk-snapshots`
查询 Runtime 回写的 SK snapshot。当前 Manager 从 `sk_tool.called/completed/failed` 和包含 `sk_snapshot` 的 artifact 事件投影生成响应。
@@ -1213,6 +1216,121 @@ 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",
"AGENT_ACCESS_TOKEN": "550e8400-e29b-41d4-a716-446655440000",
"HEICODE_AGENT_ID": "dep-b5fab27e9255"
}
}
```
响应示例:
```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`。
#### 客户端直连鉴权
模板 Agent 当前支持 HM 约定的本地访问鉴权:
- 当实例环境变量存在 `AGENT_ACCESS_TOKEN` 时,`POST /message/send`、`POST /message/stream`、`GET /tasks/{task_id}` 必须携带请求头 `X-Agent-Access-Token`
- 服务端使用常量时间比较校验 `X-Agent-Access-Token == AGENT_ACCESS_TOKEN`
- 请求头缺失时返回 `401`
- 请求头不匹配时返回 `403`
- 若实例未注入 `AGENT_ACCESS_TOKEN`,则继续兼容放行
职责边界:
- `X-Agent-Access-Token` 用于“谁有权访问这个 agent”
- A2A body 中的 `api_key` 仍用于“本次请求走谁的模型额度”
#### `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)
@@ -1322,7 +1440,7 @@ Heicode sub 模式使用扩展 Resource Grant 表达任务资源授权。Runtime
### 4.8 Artifact 回写模型
Runtime 通过 `/api/agnet/callbacks/swarm-events` 回写产物事件,Manager 将其持久化后供用户态接口查询。
Runtime 通过 `/api/agent/callbacks/runtime-events` 回写产物事件,Manager 将其持久化后供用户态接口查询。
```json
{
@@ -1458,7 +1576,7 @@ create_payload = {
}
response = requests.post(
f"{BASE_URL}/api/agnet/deployments",
f"{BASE_URL}/api/agent/sub-agile/deployments",
headers=headers,
json=create_payload
)
@@ -1471,7 +1589,7 @@ time.sleep(120) # 等待 2 分钟
# 3. 获取部署详情
response = requests.get(
f"{BASE_URL}/api/agnet/deployments/{deployment_id}",
f"{BASE_URL}/api/agent/sub-agile/deployments/{deployment_id}",
headers=headers
)
details = response.json()
@@ -1479,7 +1597,7 @@ print(f"📊 部署状态: {details['status']}")
# 4. 获取实时日志
response = requests.get(
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/logs?limit=20",
f"{BASE_URL}/api/agent/sub-agile/deployments/{deployment_id}/logs?limit=20",
headers=headers
)
logs = response.json()
@@ -1487,7 +1605,7 @@ print(f"📝 最新日志: {len(logs['logs'])} 条")
# 5. 获取资源指标
response = requests.get(
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/metrics",
f"{BASE_URL}/api/agent/sub-agile/deployments/{deployment_id}/metrics",
headers=headers
)
metrics = response.json()
@@ -1499,7 +1617,7 @@ stop_payload = {
"reason": "Task completed successfully"
}
response = requests.post(
f"{BASE_URL}/api/agnet/deployments/{deployment_id}/stop",
f"{BASE_URL}/api/agent/sub-agile/deployments/{deployment_id}/stop",
headers=headers,
json=stop_payload
)
@@ -1527,14 +1645,14 @@ headers = {
# 第一次请求
response1 = requests.post(
f"{BASE_URL}/api/agnet/deployments",
f"{BASE_URL}/api/agent/sub-agile/deployments",
headers=headers,
json=create_payload
)
# 重复请求(使用相同的 idempotency_key)
response2 = requests.post(
f"{BASE_URL}/api/agnet/deployments",
f"{BASE_URL}/api/agent/sub-agile/deployments",
headers=headers,
json=create_payload
)
@@ -1589,7 +1707,7 @@ payload = {
]
},
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key"
}
}
@@ -1604,7 +1722,7 @@ status.raise_for_status()
print(status.json()["status"])
timeline = requests.get(
f"{BASE_URL}/api/agnet/user/deployments/{run['deployment_id']}/timeline",
f"{BASE_URL}/api/agent/user/deployments/{run['deployment_id']}/timeline",
headers={"Authorization": f"Bearer {TOKEN}"}
)
timeline.raise_for_status()
@@ -1655,7 +1773,7 @@ print(len(timeline.json()["timeline"]))
```python
try:
response = requests.post(
f"{BASE_URL}/api/agnet/deployments",
f"{BASE_URL}/api/agent/sub-agile/deployments",
headers=headers,
json=create_payload
)
@@ -1756,6 +1874,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 读取接口 |
@@ -1763,7 +1882,7 @@ except requests.exceptions.HTTPError as e:
| v2.1.6 | 2026-05-29 | 修复普通 sub 真实执行后缺失 `artifact.created` 的问题,新增 `task.completed` / `task.failed` / `task.blocked` 事件,修复 deployment 与 agents 终态不一致,`/api/swarms/{id}/logs` 改为返回 Runtime 聚合摘要;部署镜像更新为 `heicode-v2-20260529120632` |
| v2.1.5 | 2026-05-28 | 文档修订:新增 v2.1.4 联调速查,补充 `/api/swarms` 最小请求、校验失败、状态响应和普通 sub 联调示例;修正 callback 当前实现为失败只记录 warning,重试/死信/replay 为后续增强 |
| v2.1.4 | 2026-05-28 | 按普通 sub 联调整改要求补齐 `/api/swarms` 参数校验、`dry_run` 拒绝、`deployment_id` 返回、detail 根路径、幂等创建和 usage/cost callback 字段;当前联调 Base URL 明确为 `http://20.212.121.126`,部署镜像更新为 `heicode-v2-20260528164612` |
| v2.1.3 | 2026-05-28 | 按普通 sub 敏捷模式任务清单补齐 `/api/agnet/deployments` 主动回调、`role_template` 兼容、callback schema、`/api/swarms/{id}` stop/status/logs/events/metrics、approval decision 接收路径,部署镜像更新为 `heicode-v2-20260528161931` |
| v2.1.3 | 2026-05-28 | 按普通 sub 敏捷模式任务清单补齐 `/api/agent/sub-agile/deployments` 主动回调、`role_template` 兼容、callback schema、`/api/swarms/{id}` stop/status/logs/events/metrics、approval decision 接收路径,部署镜像更新为 `heicode-v2-20260528161931` |
| v2.1.2 | 2026-05-28 | Agent Manager Runtime 支持按 callback 配置主动推送 status/phase/timeline/agent/tool/artifact 事件,补充发送端签名密钥解析顺序和失败策略,部署镜像更新为 `heicode-v2-20260528144233` |
| v2.1.1 | 2026-05-27 | 同步 Manager 当前实现状态:callback HMAC/旧 token 兼容、payload 投影、默认 subscribed_events、artifact/timeline/SK snapshot 查询示例、部署镜像版本 |
| v2.1.0 | 2026-05-26 | 补充 Heicode sub 模式敏捷开发契约、`/api/swarms` 兼容入口、`azkv://` secret_ref、artifact/timeline/SK snapshot 模型 |
@@ -1771,6 +1890,6 @@ except requests.exceptions.HTTPError as e:
---
**文档版本**: v2.1.10
**最后更新**: 2026-05-30
**文档版本**: v2.1.11
**最后更新**: 2026-06-04
**维护者**: Agent Manager Team
+23 -23
View File
@@ -33,10 +33,10 @@
| deployment/agent 状态一致性 | ✅ 完成 | deployment 完成或失败时,agents 会同步进入终态 |
| `/api/swarms/{id}/logs` 兜底日志 | ✅ 完成 | 返回 Runtime 聚合日志摘要,而不是固定占位文本 |
| Callback HMAC/幂等接收 | ✅ 完成 | 支持 v2.1 HMAC,兼容旧 service token |
| artifact 查询 | ✅ 完成 | `GET /api/agnet/user/deployments/{deployment_id}/artifacts` |
| timeline 查询 | ✅ 完成 | `GET /api/agnet/user/deployments/{deployment_id}/timeline` |
| SK snapshot 查询投影 | ✅ 完成 | `GET /api/agnet/user/deployments/{deployment_id}/sk-snapshots` |
| 审批 decision | ✅ 完成 | 支持 `/api/swarms/.../approvals/...` 与 `/api/agnet/deployments/.../approvals/...` |
| artifact 查询 | ✅ 完成 | `GET /api/agent/user/deployments/{deployment_id}/artifacts` |
| timeline 查询 | ✅ 完成 | `GET /api/agent/user/deployments/{deployment_id}/timeline` |
| SK snapshot 查询投影 | ✅ 完成 | `GET /api/agent/user/deployments/{deployment_id}/sk-snapshots` |
| 审批 decision | ✅ 完成 | 支持 `/api/swarms/.../approvals/...` 与 `/api/agent/sub-agile/deployments/.../approvals/...` |
---
@@ -48,17 +48,17 @@
| 接口 | 路径 | 状态 | 文件位置 |
|------|------|------|----------|
| 1️⃣ 健康检查 | `GET /api/agnet/health` | ✅ 完成 | `api/agnet/router.py:20` |
| 2️⃣ 创建部署 | `POST /api/agnet/deployments` | ✅ 完成 | `api/agnet/deployments.py:123` |
| 3️⃣ 列出部署 | `GET /api/agnet/deployments` | ✅ 完成 | `api/agnet/deployments.py:346` |
| 4️⃣ 获取部署详情 | `GET /api/agnet/deployments/{id}` | ✅ 完成 | `api/agnet/deployments.py:408` |
| 5️⃣ 停止部署 | `POST /api/agnet/deployments/{id}/stop` | ✅ 完成 | `api/agnet/deployments.py:469` |
| 6️⃣ 获取日志 | `GET /api/agnet/deployments/{id}/logs` | ✅ 完成 | `api/agnet/deployments.py:596` |
| 7️⃣ 获取事件 | `GET /api/agnet/deployments/{id}/events` | ✅ 完成 | `api/agnet/deployments.py:679` |
| 8️⃣ 获取指标 | `GET /api/agnet/deployments/{id}/metrics` | ✅ 完成 | `api/agnet/deployments.py:731` |
| 1️⃣ 健康检查 | `GET /api/agent/health` | ✅ 完成 | `api/agnet/router.py:20` |
| 2️⃣ 创建部署 | `POST /api/agent/sub-agile/deployments` | ✅ 完成 | `api/agnet/deployments.py:123` |
| 3️⃣ 列出部署 | `GET /api/agent/sub-agile/deployments` | ✅ 完成 | `api/agnet/deployments.py:346` |
| 4️⃣ 获取部署详情 | `GET /api/agent/sub-agile/deployments/{id}` | ✅ 完成 | `api/agnet/deployments.py:408` |
| 5️⃣ 停止部署 | `POST /api/agent/sub-agile/deployments/{id}/stop` | ✅ 完成 | `api/agnet/deployments.py:469` |
| 6️⃣ 获取日志 | `GET /api/agent/sub-agile/deployments/{id}/logs` | ✅ 完成 | `api/agnet/deployments.py:596` |
| 7️⃣ 获取事件 | `GET /api/agent/sub-agile/deployments/{id}/events` | ✅ 完成 | `api/agnet/deployments.py:679` |
| 8️⃣ 获取指标 | `GET /api/agent/sub-agile/deployments/{id}/metrics` | ✅ 完成 | `api/agnet/deployments.py:731` |
| 9️⃣ Runtime 兼容入口 | `POST /api/swarms` | ✅ 完成 | `api/swarm/router.py` |
| 🔟 Callback 接收 | `POST /api/agnet/callbacks/swarm-events` | ✅ 完成 | `api/agnet/callbacks.py` |
| 1️⃣1️⃣ 用户态观测 | `/api/agnet/user/deployments/{id}/{artifacts,timeline,sk-snapshots}` | ✅ 完成 | `api/agnet/callbacks.py` |
| 🔟 Callback 接收 | `POST /api/agent/callbacks/runtime-events` | ✅ 完成 | `api/agnet/callbacks.py` |
| 1️⃣1️⃣ 用户态观测 | `/api/agent/user/deployments/{id}/{artifacts,timeline,sk-snapshots}` | ✅ 完成 | `api/agnet/callbacks.py` |
**实现亮点**:
- ✅ 完整的请求/响应模型定义
@@ -220,7 +220,7 @@ def create_service_account(self, namespace: str, role: str, user_id: str):
### 1. SSE 实时日志流
**接口**: `GET /api/agnet/deployments/{id}/logs/stream`
**接口**: `GET /api/agent/sub-agile/deployments/{id}/logs/stream`
**状态**: ❌ 未实现
@@ -247,7 +247,7 @@ async def stream_logs(deployment_id: str):
### 2. 资源作用域监控快照
**接口**: `GET /api/agnet/projects/{binding_scope}/dashboard-snapshot`
**接口**: `GET /api/agent/projects/{binding_scope}/dashboard-snapshot`
**状态**: ⚠️ 后续增强
@@ -263,7 +263,7 @@ async def stream_logs(deployment_id: str):
### 3. SK 快照解析
**接口**: `POST /api/agnet/sk-snapshots/resolve`
**接口**: `POST /api/agent/sk-snapshots/resolve`
**状态**: ⚠️ 后续增强
@@ -273,7 +273,7 @@ async def stream_logs(deployment_id: str):
### 4. SK 快照查询
**接口**: `GET /api/agnet/user/deployments/{id}/sk-snapshots`
**接口**: `GET /api/agent/user/deployments/{id}/sk-snapshots`
**状态**: ✅ 已实现(v2.1.4)
@@ -402,11 +402,11 @@ async def get_deployment_logs(...):
**优先级: 中**
1. ⚪ 实现资源作用域监控快照
- `GET /api/agnet/projects/{binding_scope}/dashboard-snapshot`
- `GET /api/agent/projects/{binding_scope}/dashboard-snapshot`
2. ⚪ 实现 SK 快照功能
- `POST /api/agnet/sk-snapshots/resolve`
- `GET /api/agnet/deployments/{id}/sk-snapshots`
- `POST /api/agent/sk-snapshots/resolve`
- `GET /api/agent/sub-agile/deployments/{id}/sk-snapshots`
### Phase 3: 基础设施配置(3-5 天)
@@ -425,7 +425,7 @@ async def get_deployment_logs(...):
**优先级: 低**
1. ⚪ 实现 SSE 实时日志流
- `GET /api/agnet/deployments/{id}/logs/stream`
- `GET /api/agent/sub-agile/deployments/{id}/logs/stream`
---
@@ -458,7 +458,7 @@ async def get_deployment_logs(...):
### AKS 部署版本
**当前 AKS 上的版本是 `heicode-v2-20260529120632`**,包含:
- ✅ 完整的 Heicode Agent API (`/api/agnet/*`)
- ✅ 完整的 Heicode Agent API (`/api/agent/*`)
- ✅ `/api/swarms` Runtime 兼容入口
- ✅ Runtime 主动 callback、artifact、timeline、SK snapshot 查询
- ✅ 普通 sub 真实执行后的 `artifact.created` 与 `task.*` 终态回调
@@ -0,0 +1,564 @@
# Heicode Sub Mode Runtime 接入说明
更新时间:2026-06-01
本文档描述当前仓库作为 **Sub Agile / 普通 sub 模式 Runtime** 时,对 Manager 暴露的接入契约。
这不是客户端主调用协议。
生产客户端主调用协议应由 `heicode-manager` 的 `/api/heicode/*` 定义并统一归口。
## 1. 文档边界
当前仓库只负责:
- `sub_agile` / 普通 sub 模式 Runtime
- Manager 下发后已校验的执行计划
- Runtime 事实回传:状态、事件、产物、审批请求、诊断信息
当前仓库不负责:
- 真正的 Swarm 产品模式
- 客户端主调用协议
- 客户端展示状态裁决
- 本地修改的最终可信版本管理
- 云部署目标选择与云密钥直连
命名约定:
- `agent`:主命名,新的标准入口
- `agnet`:兼容命名,历史调用方继续可用
- `/api/swarms`:sub-mode compatibility API,不表示当前仓库实现独立 Swarm 系统
生产调用边界:
```text
客户端 -> Manager /api/heicode/sub-agile/*
Manager -> agent_management /api/agent/sub-agile/*
agent_management -> Manager /api/agent/callbacks/runtime-events
客户端 <- Manager display_status / workflow / artifacts / diagnostics
```
结论:
- 生产客户端不应直连本 Runtime
- 本文档面向 Runtime 接入方、Manager 调用方、联调工程师
## 2. 主路径与兼容路径
### 2.1 主路径
```text
GET /api/agent/health
POST /api/agent/sub-agile/deployments
GET /api/agent/sub-agile/deployments
GET /api/agent/sub-agile/deployments/{deployment_id}
POST /api/agent/sub-agile/deployments/{deployment_id}/stop
POST /api/agent/sub-agile/deployments/{deployment_id}/approvals/{approval_id}
GET /api/agent/sub-agile/deployments/{deployment_id}/logs
GET /api/agent/sub-agile/deployments/{deployment_id}/events
GET /api/agent/sub-agile/deployments/{deployment_id}/metrics
POST /api/agent/callbacks/runtime-events
GET /api/agent/callbacks/runtime-events/schema
```
### 2.2 兼容路径
```text
GET /api/agnet/health
POST /api/agnet/deployments
GET /api/agnet/deployments
GET /api/agnet/deployments/{deployment_id}
POST /api/agnet/deployments/{deployment_id}/stop
POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id}
GET /api/agnet/deployments/{deployment_id}/logs
GET /api/agnet/deployments/{deployment_id}/events
GET /api/agnet/deployments/{deployment_id}/metrics
POST /api/agnet/callbacks/swarm-events
GET /api/agnet/callbacks/swarm-events/schema
```
### 2.3 Legacy compatibility API
```text
POST /api/swarms
GET /api/swarms/{swarm_id}
GET /api/swarms/{swarm_id}/status
POST /api/swarms/{swarm_id}/stop
GET /api/swarms/{swarm_id}/logs
GET /api/swarms/{swarm_id}/events
GET /api/swarms/{swarm_id}/metrics
GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content
POST /api/swarms/{swarm_id}/approvals/{approval_id}
```
### 2.4 用户态产物 / 时间线查询
当前仓库仍保留以下内部 / 兼容查询面:
```text
GET /api/agent/user/deployments/{deployment_id}/artifacts
GET /api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content
GET /api/agent/user/deployments/{deployment_id}/timeline
GET /api/agent/user/deployments/{deployment_id}/sk-snapshots
```
说明:
- 这些路径更适合作为 Runtime / Manager 内部或兼容查询面
- 生产客户端不应直接消费这些路径
- 生产客户端应通过 Manager 的 `/api/heicode/*` 查询 artifacts / workflow / diagnostics
## 3. 状态语义与裁决边界
### 3.1 Runtime 当前返回状态集合
当前 Runtime 对外统一投影:
```text
accepted
running
waiting_approval
completed
failed
stopped
```
### 3.2 Runtime 状态不等于客户端展示状态
统一方案中应区分:
```text
client_task_status
cloud_deployment_status
runtime_execution_status
display_status
```
当前 Runtime 返回的 `status` 更接近:
```text
runtime_execution_status
```
尤其需要注意:
- Runtime `completed` 不等于用户最终看到的 `completed`
- 最终 `display_status` 必须由 Manager 根据结构化产物事实裁决
Manager 可能额外裁决出:
```text
queued
runtime_syncing
runtime_accepted
waiting_input
completed_without_deliverable
needs_codegen
offline_pending
```
推荐裁决原则:
```text
Runtime completed + has_deliverable=true + summary_only=false -> completed
Runtime completed + summary_only=true -> completed_without_deliverable 或 needs_codegen
Runtime completed + 无真实 artifact -> completed_without_deliverable 或 needs_codegen
```
## 4. 推荐请求结构
建议 Manager 传入结构化 sub mode 计划,而不是裸自然语言。
示例:
```json
{
"orchestration_plan": {
"intent_id": "task_demo_001",
"template_hint": "fastapi-crud",
"objective": "为任务管理系统生成 FastAPI CRUD 后端方案",
"sub_mode": "agile",
"user_context": {
"user_id": "user_123",
"binding_scope": "project_task_demo_001"
},
"agile_context": {
"stage": "planning",
"checkpoint": "draft_created",
"max_iterations": 1
},
"budget": {
"max_cost_usd": 1,
"max_tokens": 2000,
"max_duration_sec": 600,
"alert_threshold_pct": 80
},
"billing_context": {
"provider": "newapi",
"default_model_id": "gpt-5.4",
"allowed_model_ids": ["gpt-5.4"],
"stream": false
},
"agents": [
{
"role": "backend",
"template": "a2a_litellm_agent",
"model": "gpt-5.4",
"capabilities": ["code", "api", "test"]
}
]
},
"callback": {
"url": "https://your-manager.example.com/api/agent/callbacks/runtime-events"
}
}
```
最低必填项:
- `orchestration_plan`
- `orchestration_plan.sub_mode`
- `orchestration_plan.user_context.user_id`
- `callback.url`
## 5. 模型来源与信任边界
生产环境中,Runtime 不应信任客户端直传的模型选择。
正确边界:
```text
客户端选择模型
Manager 校验模型、套餐、权限、预算、allowed_model_ids
Manager 下发已校验 orchestration_plan
Runtime 只消费 Manager 下发的 model / billing_context / allowed_model_ids
```
约定:
- Runtime 只信任 Manager 已校验的 `orchestration_plan`
- Runtime 不应把客户端未校验的 `model` 视为最终可信配置
## 6. 回调事件
主回调入口:
```text
POST /api/agent/callbacks/runtime-events
```
兼容回调入口:
```text
POST /api/agnet/callbacks/swarm-events
```
当前支持的关键事件:
- `deployment.status_changed`
- `phase.changed`
- `timeline.updated`
- `agent.started`
- `agent.completed`
- `agent.crashed`
- `approval.requested`
- `artifact.created`
- `task.completed`
- `task.failed`
- `task.blocked`
- `sk_tool.called`
- `sk_tool.completed`
- `sk_tool.failed`
- `budget.alert`
最小消费建议:
1. `deployment.status_changed`
2. `phase.changed`
3. `artifact.created`
4. `approval.requested`
## 7. 产物语义
### 7.1 当前真实 artifact
当前 Runtime 真实产物通常以:
- `code_patch`
- `document`
返回,并提供:
- `artifact_id`
- `summary`
- `uri`
- `metadata.download_path`
### 7.2 fallback artifact
当真实 agent 没有产出具体 artifact 时,Runtime 会生成 fallback artifact。
从本次升级开始,fallback artifact 必须稳定标记:
```json
{
"metadata": {
"synthesized": true,
"summary_only": true
}
}
```
语义说明:
- `synthesized=true`:说明该产物是 Runtime 合成的兼容性结果
- `summary_only=true`:说明该产物只适合展示失败 / 总结,不应直接作为代码类任务的有效交付依据
Manager 不应将 `summary_only=true` 的 artifact 作为代码任务 `completed` 的充分条件。
### 7.3 project_folder artifact(结构性代码强制要求)
对于结构性代码交付,统一方案要求优先使用:
```text
artifact_type = project_folder
```
适用范围:
- 前后端项目
- 多文件项目
- 可部署项目
- 包含 frontend/backend/docs/deploy 等目录的结构性代码任务
约束:
- `code_patch` / `document` 仅适用于单文件、小型补丁或兼容任务
- 对于结构性代码任务,Runtime 不应长期只返回单一 `content` 正文作为主要交付形式
推荐形态:
```json
{
"artifact_id": "art_project_xxx",
"artifact_type": "project_folder",
"title": "Oracle Cloud Agency Site",
"summary": "包含 frontend、backend、docs 和部署配置的完整项目。",
"metadata": {
"root_dir": "oracle-cloud-agency-site",
"manifest_uri": "runtime://run_xxx/artifacts/art_project_xxx/manifest",
"archive_uri": "runtime://run_xxx/artifacts/art_project_xxx/archive.zip",
"content_hash": "sha256:project-tree-hash",
"file_count": 42,
"directory_count": 8
}
}
```
当前仓库现状说明:
- 结构性代码角色(如 `backend` / `frontend` / `coder` / `engineer` / `fullstack`)现在会优先产出 `project_folder`
- 普通摘要/兼容型产物仍可能是 `single_file_content`
- `project_folder` 当前已经支持 `manifest`、`archive.zip`、`files/{path}` 读取
- 更完整的 revision / conflict 协议仍属于后续增强项
接入方应按以下原则理解:
- 当前 `content` 路径仍可用于兼容读取单文件/摘要类产物
- 未来结构性代码交付应升级为 `project_folder + manifest/archive/files`
### 7.4 project_folder 读取路径
对于 `project_folder` 类型 artifact,主读取路径应是:
```text
manifest_uri
archive_uri
files/{path}
```
推荐读取顺序:
1. 先读 `manifest_uri` 获取项目文件树和 revision 信息
2. 按需通过 `files/{path}` 读取单文件
3. 需要整体下载时通过 `archive_uri`
`content` 接口定位:
- `content` 仅作为 `code_patch` / `document` / `single_file_content` 的兼容读取方式
- `project_folder` 不应依赖单一 `content` 接口作为主读取方式
## 8. 本地修改与 artifact revision(推荐扩展)
统一方案要求:
- 用户本地修改项目文件夹,不等于云端产物自动更新
- 客户端必须显式上传 local edit
- Manager 保存 `project artifact revision`
- Runtime 后续执行必须以最新 accepted revision 为基线
当前仓库尚未把这套 revision 协议实现为正式 Runtime 接口,但建议保留以下扩展方向:
```text
POST /api/agent/sub-agile/deployments/{deployment_id}/artifact-edits
```
推荐事件形态:
```json
{
"event_type": "artifact.local_edit_received",
"task_id": "task_xxx",
"deployment_id": "dep_xxx",
"runtime_deployment_id": "run_xxx",
"artifact_id": "art_project_xxx",
"project_revision": 2,
"source": "client_local_edit",
"manifest_uri": "manager://tasks/task_xxx/artifacts/art_project_xxx/revisions/2/manifest",
"archive_uri": "manager://tasks/task_xxx/artifacts/art_project_xxx/revisions/2/archive",
"content_hash": "sha256:new"
}
```
后续建议补齐的 revision 细节:
- `base_project_revision`
- `base_content_hash`
- `ARTIFACT_REVISION_CONFLICT`
- `artifact.local_edit_applied`
- `artifact.local_edit_reviewed`
- `artifact.local_edit_rejected`
推荐边界:
- Manager 负责 revision / conflict 判定
- Runtime 通过 `manifest/archive/files` 读取指定 accepted revision
## 9. 云部署生命周期(推荐扩展)
统一方案中,云部署不应由客户端直传云密钥并直接驱动 Runtime。
正确边界:
```text
客户端只选择 target / environment / resource_binding_id
Manager 负责校验、审批、凭证解析、预算和审计
Runtime 或 Deploy Worker 执行 provider adapter
部署结果通过 deployment_manifest artifact 和 deployment events 回传
```
当前 Runtime 文档仅做说明,不将云部署声明为本仓库已完整实现能力。
## 10. 真实联调建议
### 10.1 当前稳定范围
当前 Runtime 更稳定的任务类型:
- 小型单文件函数生成
- 小型 React 组件生成
- 中小型实现摘要 / 代码骨架任务
- 经输出收缩后的中型真实编程任务
### 10.2 当前高风险任务
高风险特征:
- 一次性要求完整项目所有文件
- 单 agent 输出过长代码、长解释、长测试、长部署说明
- 多角色同时高负载、每个角色都要求大体量正文
真实现象:
- 较大的任务可能在模型网关返回 `504 Gateway Time-out`
### 10.3 推荐拆分方式
建议把一个大任务拆成多个小任务:
- 先生成数据模型与 API 列表
- 再生成 CRUD 路由骨架
- 再生成测试样例
- 前端组件与样式单独生成
- reviewer 单独作为收尾检查任务
## 11. 模型网关配置要求
当前 runtime agent 需要一个真正返回模型 JSON 的 OpenAI 兼容基址。
当前线上有效配置是:
```text
https://code.xinghanlab.com/v1
```
不是:
```text
https://code.xinghanlab.com
```
如果少了 `/v1`,subagent 实际打到的会是站点 HTML,而不是模型接口,结果会出现:
- JSON 解析失败
- artifact 为空
- 或整体任务失败
建议至少确保以下配置正确:
- `HEICODE_NEWAPI_BASE_URL=https://code.xinghanlab.com/v1`
- `LITELLM_BASE_URL=https://code.xinghanlab.com/v1`
## 12. 最小验证流程
### 12.1 服务与契约验证
```bash
curl http://127.0.0.1:8000/api/agent/health
curl http://127.0.0.1:8000/api/agnet/health
PYTEST_DISABLE_PLUGIN_AUTOLOAD=1 python3 -m pytest tests/test_sub_mode_runtime_contract.py -q
```
### 12.2 真实 smoke 验证
建议至少跑一条小型真实编程任务,例如:
- 生成 Python 工具函数 + pytest
- 生成 React 小组件 + CSS
成功标准:
1. 创建响应返回 `accepted`
2. Runtime 最终状态到 `completed`
3. `artifacts` 非空
4. `artifacts/{artifact_id}/content` 返回可读正文,而不是 UUID、HTML 或空串
5. fallback artifact 必须带 `synthesized=true` / `summary_only=true`
## 13. 相关文件
主入口与兼容入口:
- [api/agent/router.py](/Users/mac/Projects/agent-manager/tools/agent-manager/api/agent/router.py)
- [api/agnet/router.py](/Users/mac/Projects/agent-manager/tools/agent-manager/api/agnet/router.py)
- [api/swarm/router.py](/Users/mac/Projects/agent-manager/tools/agent-manager/api/swarm/router.py)
状态投影:
- [api/status_projection.py](/Users/mac/Projects/agent-manager/tools/agent-manager/api/status_projection.py)
K8s 部署与配置:
- [k8s/agent-manager-deployment.yaml](/Users/mac/Projects/agent-manager/tools/agent-manager/k8s/agent-manager-deployment.yaml)
- [k8s/agent-manager-configmap.yaml](/Users/mac/Projects/agent-manager/tools/agent-manager/k8s/agent-manager-configmap.yaml)
- [k8s/README.md](/Users/mac/Projects/agent-manager/tools/agent-manager/k8s/README.md)
---
如果接入方只想记最关键的四件事,只需要记:
1. 生产客户端只调用 Manager 的 `/api/heicode/*`
2. Manager 调 Runtime 统一走 `/api/agent/sub-agile/*`
3. Runtime `completed` 只是 runtime_execution_status,不等于最终 display_status
4. `summary_only=true` 的 artifact 不能直接当代码类任务有效交付物
@@ -0,0 +1,297 @@
# Heicode Template Agent Runtime 对接文档
更新时间:2026-06-04
本文档只描述 **模板 Agent (`/agents`)** 这一条联调链路,适用于 Heicode Manager 创建常驻 A2A Agent、查询状态、停止、删除,以及客户端直连 Agent 的场景。
这不是 `/api/agent/sub-agile/*` 或 `/api/swarms/*` 的 sub-mode runtime 文档。
如果你们对接的是普通 sub 模式 Runtime,请看 [HEICODE_API_INTEGRATION.md](/Users/mac/Projects/agent-manager/tools/agent-manager/docs/HEICODE_API_INTEGRATION.md)。
## 1. 场景说明
模板 Agent 的典型链路如下:
1. HM 调 `POST /agents` 创建一个模板 Agent。
2. AM 返回 `runtime_id` / `agent_id` / `id`、`subdomain`、`status`。
3. HM 轮询 `GET /agents/{agent_name}` 获取生命周期状态。
4. 客户端拿到 `subdomain` 后,直连 Agent 的 A2A 接口:
- `POST /message/send`
- `POST /message/stream`
- `GET /.well-known/agent.json`
- `GET /health`
5. 如需停止或删除:
- `POST /agents/{agent_name}/stop`
- `DELETE /agents/{agent_name}`
## 2. Agent Manager 生命周期接口
### 2.1 接口列表
| 方法 | 路径 | 说明 |
|------|------|------|
| `POST` | `/agents` | 创建模板 Agent |
| `GET` | `/agents/{agent_name}` | 查询模板 Agent 生命周期状态 |
| `POST` | `/agents/{agent_name}/stop` | 幂等停止模板 Agent |
| `DELETE` | `/agents/{agent_name}` | 删除模板 Agent |
| `GET` | `/agents/{agent_name}/status` | 查询详细 Pod/容器状态 |
| `GET` | `/agents/{agent_name}/metrics` | 查询资源使用情况 |
### 2.2 创建接口
```http
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": {
"OPENAI_BASE_URL": "https://code.xinghanlab.com/v1",
"OPENAI_API_KEY": "sk-xxxx",
"MODEL_NAME": "gpt-5.4",
"AGENT_ROLE_NAME": "architect",
"AGENT_INSTRUCTION_TEXT": "# Role\nYou are architect",
"AGENT_ACCESS_TOKEN": "550e8400-e29b-41d4-a716-446655440000",
"HEICODE_AGENT_ID": "dep-b5fab27e9255"
}
}
```
当前响应中,HM 重点关心这些字段:
| 字段 | 说明 |
|------|------|
| `runtime_id` | 当前等于 Agent 名称,可用于后续状态/停止/删除 |
| `agent_id` | 当前等于 Agent 名称 |
| `id` | 当前等于 Agent 名称 |
| `status` | 当前生命周期状态 |
| `runtime_status` | `status` 的兼容别名 |
| `state` | `status` 的兼容别名 |
| `subdomain` | 优先取 `access_info.domain`,否则回退到 `access_info.external_ip` |
| `access_info` | 访问地址详情 |
响应示例:
```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"
}
}
```
## 3. 状态接口
### 3.1 生命周期状态
```http
GET /agents/{agent_name}
```
这个接口给 HM 轮询用,返回平铺生命周期字段,便于直接解析:
```json
{
"runtime_id": "dep-b5fab27e9255",
"agent_id": "dep-b5fab27e9255",
"id": "dep-b5fab27e9255",
"name": "dep-b5fab27e9255",
"namespace": "agent-dep-b5fab27e9255",
"status": "running",
"runtime_status": "running",
"state": "running",
"framework": "A2A",
"template": "coding_a2a_agent",
"subdomain": "dep-b5fab27e9255.taijiagnet.com",
"access_token": null,
"access_info": {
"domain": "dep-b5fab27e9255.taijiagnet.com"
}
}
```
当前 `status` / `runtime_status` / `state` 可能值:
- `pending`
- `running`
- `stopped`
- `failed`
### 3.2 详细 Pod 状态
```http
GET /agents/{agent_name}/status
```
这个接口偏排障用途,除了生命周期状态外,还会返回:
- `health_status`
- `containers`
- `resources`
- `conditions`
- `access_info`
适合 AM / 运维 / 联调同学排查“为什么 agent 没 ready”这类问题。
### 3.3 资源指标
```http
GET /agents/{agent_name}/metrics
```
返回请求/限制和实时资源使用信息。
## 4. 停止与删除
### 4.1 停止
```http
POST /agents/{agent_name}/stop
```
响应示例:
```json
{
"status": "success",
"message": "Agent dep-b5fab27e9255 已停止"
}
```
约定:
- 这是 **幂等** 的停止接口。
- 停止时会删除当前运行 Pod,并把数据库状态收敛为 `stopped`。
- 停止后仍可继续查询 `GET /agents/{agent_name}`。
### 4.2 删除
```http
DELETE /agents/{agent_name}
```
约定:
- 会清理 DNS、K8s namespace 和数据库记录。
- 当前已修复旧版本里模板 Agent 删除时可能触发的 `UnboundLocalError` 500。
## 5. 客户端直连 Agent 的鉴权
模板 Agent 当前支持 HM 约定的 **本地校验** 访问鉴权。
### 5.1 规则
- HM 创建 Agent 时,会把 `AGENT_ACCESS_TOKEN` 注入到 Agent env。
- 客户端访问 Agent 时,请把这把令牌放到请求头:
```http
X-Agent-Access-Token: <AGENT_ACCESS_TOKEN>
```
- Agent 本地使用常量时间比较校验:
```text
X-Agent-Access-Token == AGENT_ACCESS_TOKEN
```
- 命中则放行,不命中拒绝。
### 5.2 当前生效范围
以下 Agent 直连接口会做本地校验:
| 方法 | 路径 |
|------|------|
| `POST` | `/message/send` |
| `POST` | `/message/stream` |
| `GET` | `/tasks/{task_id}` |
### 5.3 返回约定
- 缺少 `X-Agent-Access-Token`:返回 `401`
- `X-Agent-Access-Token` 不匹配:返回 `403`
- 如果实例没有注入 `AGENT_ACCESS_TOKEN`:继续兼容放行
### 5.4 和模型 `api_key` 的区别
- `X-Agent-Access-Token`:控制“谁有权访问这个 agent”
- A2A body 里的 `api_key`:控制“本次请求用谁的模型额度”
两者职责分离,不互相替代。
## 6. Agent 直连接口
客户端从 HM 拿到 `subdomain` 后,可以直连这些 Agent 接口:
| 方法 | 路径 | 说明 |
|------|------|------|
| `GET` | `/health` | 健康检查 |
| `GET` | `/.well-known/agent.json` | A2A agent card |
| `POST` | `/message/send` | 同步 A2A 调用 |
| `POST` | `/message/stream` | 流式 A2A 调用 |
| `GET` | `/tasks/{task_id}` | 查询任务状态 |
如果实例启用了访问鉴权,`/health` 和 `/.well-known/agent.json` 仍可访问;响应中会带:
- `auth_required`
- `agent_id`
- `authentication`(agent card 中)
## 7. 当前建议
1. HM 轮询状态时优先用 `GET /agents/{agent_name}`。
2. 排障时再看 `GET /agents/{agent_name}/status`。
3. 客户端直连前,先确认 HM 已拿到 `subdomain` 和 `access_token`。
4. 若模板 Agent 对外公网暴露,建议始终注入 `AGENT_ACCESS_TOKEN`,不要依赖兼容放行。
## 8. 运行时健壮性说明
当前 `coding_a2a_agent` 已补充以下健壮性行为:
- 未绑定 git 资源时,如果远端工作区目录不存在,runtime 会先创建空的 `workspace.root_dir`
- 绑定 git 资源时,runtime 会在真正执行任务前自动准备工作区
- 如果 git clone、目标目录状态或分支切换失败,`POST /message/send` 会返回结构化 JSON-RPC error,而不是直接返回 uvicorn 500
典型错误形态:
```json
{
"jsonrpc": "2.0",
"id": "req-1",
"error": {
"code": -32010,
"message": "git workspace preparation failed",
"data": {
"code": "git_prepare_failed",
"stage": "git_prepare_workspace",
"repo_url": "https://example.com/acme/demo.git",
"branch": "main",
"workspace_root": "/workspace",
"returncode": 128,
"stderr": "fatal: repository not found"
}
}
}
```
+15 -15
View File
@@ -14,14 +14,14 @@
核心变化:
- `/api/swarms` 新增 Runtime 兼容入口,可接收 Heicode Manager 的结构化 `orchestration_plan`。
- `/api/agnet/deployments` 支持普通 sub 结构化计划,并会主动发出 Runtime 生命周期 callback。
- `/api/agent/sub-agile/deployments` 支持普通 sub 结构化计划,并会主动发出 Runtime 生命周期 callback。
- 修复普通 sub 真实执行后缺失 `artifact.created` 的问题,并补齐用户态 artifacts 可见性。
- 新增 `task.completed` / `task.failed` / `task.blocked` 事件,用于补齐普通 sub 子任务终态。
- 修复 deployment 已完成但 `agents[].status` 仍为 `running` 的状态不一致问题。
- `/api/swarms/{swarm_id}/logs` 不再返回 Phase 2 固定占位文本,而是输出 Runtime 聚合日志摘要。
- Callback 协议升级到 v2.1 形态,支持 HMAC 签名、幂等事件、`payload.*` 格式和旧 token 过渡兼容。
- 新增 artifact、timeline、SK snapshot 用户态查询接口,数据由 Runtime callback event 投影生成。
- 新增审批 decision 接收路径,覆盖 `/api/swarms` 和 `/api/agnet/deployments` 两种运行入口。
- 新增审批 decision 接收路径,覆盖 `/api/swarms` 和 `/api/agent/sub-agile/deployments` 两种运行入口。
- K8s/Docker 部署配置补充 Heicode、Vault、Redis、模型网关相关环境变量和代码目录。
---
@@ -55,10 +55,10 @@
响应兼容:
- `deployment_id` 与 `swarm_id` 同时返回;当前两者同值。
- `/api/agnet/deployments/{deployment_id}` 可查询 `/api/swarms` 创建出的 run。
- `/api/agnet/deployments/{deployment_id}/stop` 可停止 `/api/swarms` 创建出的 run。
- `/api/agent/sub-agile/deployments/{deployment_id}` 可查询 `/api/swarms` 创建出的 run。
- `/api/agent/sub-agile/deployments/{deployment_id}/stop` 可停止 `/api/swarms` 创建出的 run。
### 2.2 `/api/agnet/deployments` 普通 sub 兼容
### 2.2 `/api/agent/sub-agile/deployments` 普通 sub 兼容
创建部署现在可以直接接收结构化 `orchestration_plan`,并将字段提升到旧版模型:
@@ -94,7 +94,7 @@
### 3.1 Runtime 主动回调
`/api/agnet/deployments` 和 `/api/swarms` 创建的任务会根据 `callback.subscribed_events` 主动推送事件。
`/api/agent/sub-agile/deployments` 和 `/api/swarms` 创建的任务会根据 `callback.subscribed_events` 主动推送事件。
默认事件集:
@@ -126,7 +126,7 @@
新增接收接口:
```http
POST /api/agnet/callbacks/swarm-events
POST /api/agent/callbacks/runtime-events
```
支持能力:
@@ -142,7 +142,7 @@ POST /api/agnet/callbacks/swarm-events
联调 schema 接口:
```http
GET /api/agnet/callbacks/swarm-events/schema
GET /api/agent/callbacks/runtime-events/schema
```
该接口只返回事件类型、分类、必填字段、阶段枚举和 artifact 类型,不返回 token 或明文密钥。
@@ -153,9 +153,9 @@ GET /api/agnet/callbacks/swarm-events/schema
| 方法 | 路径 | 数据来源 |
|------|------|----------|
| `GET` | `/api/agnet/user/deployments/{deployment_id}/artifacts` | `artifact.created` callback payload |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/timeline` | timeline、phase、agent、approval、budget、artifact、SK tool 事件合并 |
| `GET` | `/api/agnet/user/deployments/{deployment_id}/sk-snapshots` | `sk_tool.*` 与携带 `sk_snapshot` 的 artifact 事件 |
| `GET` | `/api/agent/user/deployments/{deployment_id}/artifacts` | `artifact.created` callback payload |
| `GET` | `/api/agent/user/deployments/{deployment_id}/timeline` | timeline、phase、agent、approval、budget、artifact、SK tool 事件合并 |
| `GET` | `/api/agent/user/deployments/{deployment_id}/sk-snapshots` | `sk_tool.*` 与携带 `sk_snapshot` 的 artifact 事件 |
注意:当前 artifact/timeline/SK snapshot 不是独立表字段化存储,而是由 callback event payload 投影生成。
@@ -168,7 +168,7 @@ GET /api/agnet/callbacks/swarm-events/schema
| 场景 | 接口 |
|------|------|
| `/api/swarms` run | `POST /api/swarms/{swarm_id}/approvals/{approval_id}` |
| 普通 deployment | `POST /api/agnet/deployments/{deployment_id}/approvals/{approval_id}` |
| 普通 deployment | `POST /api/agent/sub-agile/deployments/{deployment_id}/approvals/{approval_id}` |
decision 只接受:
@@ -221,14 +221,14 @@ Docker 镜像:
## 7. 建议联调清单
1. 调用 `GET /api/agnet/health` 确认服务可用。
2. 调用 `GET /api/agnet/callbacks/swarm-events/schema` 确认 callback schema 与事件类型。
1. 调用 `GET /api/agent/health` 确认服务可用。
2. 调用 `GET /api/agent/callbacks/runtime-events/schema` 确认 callback schema 与事件类型。
3. 使用 `POST /api/swarms` 创建普通 sub 敏捷 run,并传入 `X-Idempotency-Key`。
4. 重复第 3 步确认幂等返回已有 run。
5. 使用缺失 `callback.url`、缺失 `user_context.user_id`、`dry_run:true` 的 payload 验证 `422`。
6. 查询 `/api/swarms/{swarm_id}` 和 `/api/swarms/{swarm_id}/status` 验证 `deployment_id` / `swarm_id` 兼容。
7. 验证普通 sub 实际执行后会收到 `task.completed` / `task.failed` 回调。
8. 验证 Runtime 主动 callback 是否写入 `/api/agnet/user/deployments/{deployment_id}/timeline`。
8. 验证 Runtime 主动 callback 是否写入 `/api/agent/user/deployments/{deployment_id}/timeline`。
9. 验证普通 sub 实际执行后会收到 `artifact.created`,并查询 artifacts 不再为 0。
10. 发送 `sk_tool.completed` 或带 `sk_snapshot` 的 artifact callback 后查询 SK snapshots。
11. 触发或模拟 `approval.requested` 后调用 approval decision 接口验证 `approved` / `rejected`。
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# 生成自签名证书脚本
# 用于 OPENCLAW AKS 部署的 HTTPS 访问
set -e
# 配置变量
DOMAIN="${1:-openclaw.yourdomain.com}" # 从命令行参数获取域名,默认为 openclaw.yourdomain.com
NAMESPACE="${2:-openclaw}" # Kubernetes 命名空间
SECRET_NAME="${3:-openclaw-tls}" # Kubernetes Secret 名称
CERT_DIR="./certs"
DAYS_VALID=365 # 证书有效期(天)
echo "=========================================="
echo "生成自签名证书 for OPENCLAW"
echo "=========================================="
echo "域名: $DOMAIN"
echo "命名空间: $NAMESPACE"
echo "Secret 名称: $SECRET_NAME"
echo "=========================================="
# 创建证书目录
mkdir -p "$CERT_DIR"
# 生成私钥
echo "1. 生成私钥..."
openssl genrsa -out "$CERT_DIR/tls.key" 2048
# 生成证书签名请求 (CSR)
echo "2. 生成证书签名请求..."
openssl req -new -key "$CERT_DIR/tls.key" -out "$CERT_DIR/tls.csr" \
-subj "/C=CN/ST=Beijing/L=Beijing/O=OpenClaw/CN=$DOMAIN"
# 生成自签名证书
echo "3. 生成自签名证书..."
openssl x509 -req -days $DAYS_VALID -in "$CERT_DIR/tls.csr" -signkey "$CERT_DIR/tls.key" \
-out "$CERT_DIR/tls.crt" \
-extensions v3_req \
-extfile <(cat <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = $DOMAIN
DNS.2 = *.$DOMAIN
DNS.3 = localhost
IP.1 = 127.0.0.1
EOF
)
# 清理 CSR 文件
rm -f "$CERT_DIR/tls.csr"
echo "4. 证书生成完成!"
echo ""
echo "证书文件:"
echo " - 私钥: $CERT_DIR/tls.key"
echo " - 证书: $CERT_DIR/tls.crt"
echo ""
# 创建 Kubernetes Secret
echo "5. 创建 Kubernetes Secret..."
kubectl create secret tls "$SECRET_NAME" \
--cert="$CERT_DIR/tls.crt" \
--key="$CERT_DIR/tls.key" \
--namespace="$NAMESPACE" \
--dry-run=client -o yaml | kubectl apply -f -
echo ""
echo "=========================================="
echo "✅ 完成!"
echo "=========================================="
echo "Secret '$SECRET_NAME' 已创建在命名空间 '$NAMESPACE' 中"
echo ""
echo "下一步:"
echo "1. 更新 deploay.yaml 中的 Ingress 配置,添加 TLS 部分"
echo "2. 将域名 '$DOMAIN' 指向你的 AKS Ingress IP"
echo "3. 访问 https://$DOMAIN (浏览器会显示安全警告,这是正常的)"
echo ""
echo "注意:自签名证书会在浏览器中显示安全警告,"
echo " 需要手动接受证书才能访问。"
echo "=========================================="
+62 -378
View File
@@ -1,414 +1,98 @@
# Agent Manager Kubernetes部署文档
# Agent Manager K8s 部署说明
## 概述
本文档对应当前仓库的 **Heicode Sub 模式 Runtime** 部署方式。
本文档说明如何在Azure Kubernetes Service (AKS)上部署Agent Manager服务。
当前接口边界:
## 前提条件
- 主接口:`/api/agent/sub-agile/*`
- 主回调:`/api/agent/callbacks/runtime-events`
- 兼容接口:`/api/agnet/*`
- 兼容 Runtime 入口:`/api/swarms/*`
1. **Azure资源**
- Azure订阅
- AKS集群
- Azure Container Registry (ACR)
- Azure DNS Zone(用于自动配置域名)
## 目录与用途
2. **本地工具**
- `kubectl` (Kubernetes命令行工具)
- `az` (Azure CLI)
- Docker (用于构建镜像)
- `agent-manager-namespace.yaml`: 运行命名空间
- `agent-manager-rbac.yaml`: ServiceAccount、ClusterRole、ClusterRoleBinding
- `agent-manager-secret.yaml`: 运行时 Secret
- `agent-manager-configmap.yaml`: 运行时配置
- `agent-manager-deployment.yaml`: 主部署清单
- `agent-manager-service.yaml`: LoadBalancer 服务
- `deploy.sh`: 标准部署脚本
- `deploy-with-kubeconfig.sh`: 使用 `kubeconfig-secret` 的部署脚本
3. **权限要求**
- AKS集群的管理员权限
- ACR的推送权限
- DNS Zone的管理权限
## 关键约定
## 部署步骤
- 命名空间统一使用 `agent-manager`
- K8s 探活统一检查 `GET /api/agent/health`
- 运行时配置中的 `NAMESPACE_PREFIX` 已更新为 `agent`
- `/api/swarms` 仅作为 sub-mode compatibility API 保留,不表示本仓库实现独立 Swarm 产品
### 1. 配置Azure凭据
#### 1.1 创建Service Principal(如果还没有)
## 标准部署
```bash
# 创建Service Principal
az ad sp create-for-rbac \
--name "agent-manager-sp" \
--role contributor \
--scopes /subscriptions/{subscription-id}
# 输出示例:
# {
# "appId": "c5ba26db-f180-425f-bac3-93708d853988",
# "displayName": "agent-manager-sp",
# "password": "ydt8Q~...",
# "tenant": "263c3ff6-1be5-4141-8308-b188464fb297"
# }
```
#### 1.2 配置DNS权限(重要!⚠️)
**Agent Manager需要DNS Zone Contributor权限才能为创建的agent自动配置域名。**
使用提供的脚本配置DNS权限:
```bash
# 方法1:使用自动化脚本(推荐)
bash scripts/setup_dns_permissions.sh
# 方法2:手动配置
AZURE_CLIENT_ID="your-service-principal-app-id"
AZURE_SUBSCRIPTION_ID="your-subscription-id"
AZURE_RESOURCE_GROUP="your-resource-group"
AZURE_DNS_ZONE="your-dns-zone.com"
DNS_ZONE_ID="/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$AZURE_RESOURCE_GROUP/providers/Microsoft.Network/dnsZones/$AZURE_DNS_ZONE"
az role assignment create \
--assignee $AZURE_CLIENT_ID \
--role "DNS Zone Contributor" \
--scope $DNS_ZONE_ID
```
验证权限:
```bash
az role assignment list \
--assignee $AZURE_CLIENT_ID \
--scope $DNS_ZONE_ID \
--output table
```
#### 1.3 更新Kubernetes Secret
编辑 `k8s/agent-manager-secret.yaml`:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: agent-manager-secret
namespace: agent-manager
type: Opaque
stringData:
AZURE_TENANT_ID: "263c3ff6-1be5-4141-8308-b188464fb297"
AZURE_CLIENT_ID: "c5ba26db-f180-425f-bac3-93708d853988"
AZURE_CLIENT_SECRET: "your-client-secret"
AZURE_SUBSCRIPTION_ID: "45d7a360-af09-40fc-9afc-56dc475245ec"
AZURE_RESOURCE_GROUP: "taiji-ai-v0"
AZURE_DNS_ZONE: "taijiagnet.com"
```
### 2. 配置ACR访问
创建ACR secret:
```bash
# 获取ACR登录服务器
ACR_NAME="your-acr-name"
ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io"
# 创建Docker registry secret
kubectl create secret docker-registry acr-secret \
--namespace agent-manager \
--docker-server=$ACR_LOGIN_SERVER \
--docker-username=$AZURE_CLIENT_ID \
--docker-password=$AZURE_CLIENT_SECRET
```
或使用脚本:
```bash
bash k8s/create-acr-secret.sh
```
### 3. 构建和推送镜像
```bash
# 构建镜像
docker build -t $ACR_LOGIN_SERVER/agent-manager:latest .
# 登录ACR
az acr login --name $ACR_NAME
# 推送镜像
docker push $ACR_LOGIN_SERVER/agent-manager:latest
```
### 4. 部署到Kubernetes
```bash
# 创建命名空间
kubectl apply -f k8s/agent-manager-namespace.yaml
# 创建RBAC(ServiceAccount、Role、RoleBinding)
kubectl apply -f k8s/agent-manager-rbac.yaml
# 创建Secret(Azure凭据)
kubectl apply -f k8s/agent-manager-secret.yaml
# 创建ACR Secret
kubectl apply -f k8s/acr-secret.yaml
# 创建ConfigMap(可选)
kubectl apply -f k8s/agent-manager-configmap.yaml
# 创建Deployment
kubectl apply -f k8s/acr-secret.yaml
kubectl apply -f k8s/agent-manager-deployment.yaml
# 创建Service(LoadBalancer)
kubectl apply -f k8s/agent-manager-service.yaml
```
或使用一键部署脚本:
或直接执行:
```bash
bash k8s/deploy.sh
```
### 5. 验证部署
## 使用 kubeconfig Secret 的部署
先生成 Secret:
```bash
bash k8s/create-kubeconfig-secret.sh
```
再执行:
```bash
bash k8s/deploy-with-kubeconfig.sh
```
## 部署后检查
```bash
# 检查Pod状态
kubectl get pods -n agent-manager
# 检查Service和外网IP
kubectl get svc -n agent-manager
# 查看日志
kubectl logs -n agent-manager deployment/agent-manager
# 测试健康检查
AGENT_MANAGER_IP=$(kubectl get svc agent-manager -n agent-manager -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl http://$AGENT_MANAGER_IP/
kubectl port-forward -n agent-manager svc/agent-manager 8000:80
curl http://127.0.0.1:8000/api/agent/health
curl http://127.0.0.1:8000/api/agnet/health
```
### 6. 测试Agent创建
## 运行时配置项
```bash
# 创建测试agent
curl -X POST http://$AGENT_MANAGER_IP/agents \
-H "Content-Type: application/json" \
-d '{
"name": "test-agent",
"template": "echo_agent",
"framework": "API",
"config": {
"user_id": "test-user"
}
}'
`agent-manager-configmap.yaml` 中与本次对接直接相关的键:
# 检查返回结果应包含:
# - external_ip: 外网IP地址
# - domain: 自动配置的域名 (test-agent.taijiagnet.com)
# - domain_url: 域名访问地址
# - recommended: 推荐访问地址(域名)
- `NAMESPACE`
- `REDIS_URL`
- `HEICODE_NEWAPI_BASE_URL`
- `LITELLM_BASE_URL`
- `NAMESPACE_PREFIX`
- `RUNTIME_ARTIFACT_*`
# 查看agent状态
curl http://$AGENT_MANAGER_IP/agents/test-agent/status
`agent-manager-secret.yaml` 中至少需要正确配置:
# 测试域名访问
curl http://test-agent.taijiagnet.com/
- `HEICODE_SERVICE_TOKEN`
- `AZURE_*`
- `AZURE_STORAGE_*`
- `VAULT_TOKEN`(如果当前环境启用 Vault)
# 清理测试agent
curl -X DELETE http://$AGENT_MANAGER_IP/agents/test-agent
```
## 发布建议
## 目录结构
更新代码后,建议同步修改:
```
k8s/
├── README.md # 本文档
├── agent-manager-namespace.yaml # Namespace定义
├── agent-manager-rbac.yaml # RBAC配置(ServiceAccount、Role等)
├── agent-manager-secret.yaml # Azure凭据Secret
├── agent-manager-configmap.yaml # 配置文件ConfigMap
├── agent-manager-deployment.yaml # Deployment定义
├── agent-manager-service.yaml # LoadBalancer Service定义
├── acr-secret.yaml # ACR访问Secret
├── create-acr-secret.sh # 创建ACR Secret脚本
└── deploy.sh # 一键部署脚本
```
## 常见问题
### Q1: Agent创建后没有返回域名信息
**症状:** 创建agent时返回外网IP但没有`domain`字段。
**原因:** Service Principal缺少DNS Zone的写权限。
**解决方案:**
```bash
# 运行DNS权限配置脚本
bash scripts/setup_dns_permissions.sh
# 或手动分配权限(见上文"配置DNS权限"部分)
```
详细信息见:`docs/DNS_ISSUE_FIX_REPORT.md`
### Q2: Pod启动失败,提示ImagePullBackOff
**原因:** 无法从ACR拉取镜像。
**解决方案:**
1. 检查ACR secret是否正确创建
2. 验证Service Principal有ACR的pull权限
3. 确认镜像名称和标签正确
```bash
# 检查ACR secret
kubectl get secret acr-secret -n agent-manager
# 重新创建ACR secret
bash k8s/create-acr-secret.sh
```
### Q3: LoadBalancer IP一直处于Pending状态
**原因:** AKS集群配置或云提供商问题。
**解决方案:**
1. 检查AKS集群是否支持LoadBalancer
2. 查看Service事件:`kubectl describe svc agent-manager -n agent-manager`
3. 确认Azure订阅有足够的配额
### Q4: 如何更新部署
```bash
# 方法1:修改YAML文件后重新应用
kubectl apply -f k8s/agent-manager-deployment.yaml
# 方法2:更新镜像
kubectl set image deployment/agent-manager \
agent-manager=your-acr.azurecr.io/agent-manager:new-tag \
-n agent-manager
# 方法3:编辑Deployment
kubectl edit deployment agent-manager -n agent-manager
# 查看滚动更新状态
kubectl rollout status deployment/agent-manager -n agent-manager
```
### Q5: 如何查看日志
```bash
# 查看所有Pod日志
kubectl logs -n agent-manager -l app=agent-manager
# 查看特定Pod日志
kubectl logs -n agent-manager <pod-name>
# 实时跟踪日志
kubectl logs -n agent-manager -l app=agent-manager -f
# 查看前一个容器的日志(如果Pod重启过)
kubectl logs -n agent-manager <pod-name> --previous
```
## 监控和维护
### 资源使用
```bash
# 查看Pod资源使用
kubectl top pods -n agent-manager
# 查看Node资源使用
kubectl top nodes
```
### 扩缩容
```bash
# 手动扩容
kubectl scale deployment agent-manager \
--replicas=3 \
-n agent-manager
# 自动扩缩容(HPA)
kubectl autoscale deployment agent-manager \
--cpu-percent=80 \
--min=2 \
--max=10 \
-n agent-manager
```
### 健康检查
Agent Manager提供以下健康检查端点:
- `GET /` - 基本健康检查
- `GET /templates` - 模板列表(验证数据库连接)
- `GET /agents` - Agent列表(验证K8s连接)
## 安全最佳实践
1. **Secret管理**
- 不要将Secret提交到版本控制
- 使用Azure Key Vault或Kubernetes Secrets加密
- 定期轮换凭据
2. **RBAC**
- 使用最小权限原则
- 为不同环境使用不同的Service Principal
- 定期审计权限分配
3. **网络安全**
- 考虑使用Private LoadBalancer
- 配置Network Policy限制Pod间通信
- 使用Ingress Controller配置TLS
4. **镜像安全**
- 定期扫描镜像漏洞
- 使用最新的基础镜像
- 不要在镜像中包含敏感信息
## 故障排查
### 诊断命令
```bash
# 检查所有资源
kubectl get all -n agent-manager
# 查看Pod详情
kubectl describe pod <pod-name> -n agent-manager
# 查看事件
kubectl get events -n agent-manager --sort-by='.lastTimestamp'
# 检查ServiceAccount
kubectl get sa -n agent-manager
kubectl describe sa agent-manager-sa -n agent-manager
# 检查RoleBinding
kubectl get rolebinding -n agent-manager
kubectl describe rolebinding agent-manager-role-binding -n agent-manager
# 进入Pod调试
kubectl exec -it <pod-name> -n agent-manager -- /bin/bash
```
### 日志级别
在Deployment中设置环境变量调整日志级别:
```yaml
env:
- name: LOG_LEVEL
value: "DEBUG" # DEBUG, INFO, WARNING, ERROR
```
## 参考资料
- [Kubernetes官方文档](https://kubernetes.io/docs/)
- [Azure Kubernetes Service文档](https://docs.microsoft.com/azure/aks/)
- [Azure DNS文档](https://docs.microsoft.com/azure/dns/)
- [Agent Manager API文档](../docs/API_DOCUMENTATION.md)
- [DNS问题修复报告](../docs/DNS_ISSUE_FIX_REPORT.md)
## 联系支持
如有问题,请:
1. 查看本文档的常见问题部分
2. 查看`docs/DNS_ISSUE_FIX_REPORT.md`
3. 查看agent-manager日志
4. 联系开发团队
1. 镜像 tag
2. `agent-manager-deployment.yaml` 中的 `image`
3. 对接文档中的版本记录
4. 部署后健康检查与契约测试结果
+1 -1
View File
@@ -4,5 +4,5 @@ data:
kind: Secret
metadata:
name: acr-secret
namespace: default
namespace: agent-manager
type: kubernetes.io/dockerconfigjson
+6 -6
View File
@@ -7,11 +7,11 @@ data:
NAMESPACE: "agent-manager"
DATABASE_URL: "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taijiagnet"
# Heicode Integration (NEW)
# Heicode sub-mode runtime integration
REDIS_URL: "redis://localhost:6379/0"
HEICODE_NEWAPI_BASE_URL: "https://code.xinghanlab.com"
LITELLM_BASE_URL: "http://litellm-service:8000"
NAMESPACE_PREFIX: "agnet"
HEICODE_NEWAPI_BASE_URL: "https://code.xinghanlab.com/v1"
LITELLM_BASE_URL: "https://code.xinghanlab.com/v1"
NAMESPACE_PREFIX: "agent"
MAX_CONCURRENT_DEPLOYMENTS_PER_USER: "10"
MAX_CONCURRENT_DEPLOYMENTS_PER_SCOPE: "50"
RUNTIME_ARTIFACT_BACKEND: "azblob"
@@ -28,8 +28,8 @@ data:
# Azure DNS 配置(如果需要)
AZURE_DNS_ZONE: "taijiagnet.com"
AZURE_SUBSCRIPTION_ID: "45d7a360-af09-40fc-9afc-56dc475245ec"
AZURE_RESOURCE_GROUP: "taiji-ai-test"
AZURE_RESOURCE_GROUP: "taiji-ai-v0"
# Gitee 配置(非敏感信息)
GITEE_API_URL: "http://gitee.ath.cx:3000/api/v1"
GITEE_BASE_URL: "http://gitee.ath.cx:3000"
+14 -3
View File
@@ -18,9 +18,20 @@ spec:
# 使用专用的 ServiceAccount
serviceAccountName: agent-manager
# ARM 架构节点选择器(确保 Pod 调度到 ARM64 节点)
nodeSelector:
kubernetes.io/arch: arm64
# 容忍度(如果 ARM64 节点有污点,取消注释以下配置)
# tolerations:
# - key: "kubernetes.io/arch"
# operator: "Equal"
# value: "arm64"
# effect: "NoSchedule"
containers:
- name: agent-manager
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-20260529232620
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604230600-arm64
imagePullPolicy: Always
ports:
@@ -91,7 +102,7 @@ spec:
# 健康检查
livenessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
@@ -100,7 +111,7 @@ spec:
readinessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
+1 -1
View File
@@ -4,7 +4,7 @@ metadata:
name: agent-manager-role
rules:
- apiGroups: [""]
resources: ["namespaces", "pods", "services", "configmaps", "secrets"]
resources: ["namespaces", "pods", "services", "configmaps", "secrets", "persistentvolumeclaims"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["deployments", "replicasets"]
+1 -1
View File
@@ -29,7 +29,7 @@ kubectl create secret docker-registry acr-secret \
--docker-server=$REGISTRY_URL \
--docker-username=$REGISTRY_USERNAME \
--docker-password=$REGISTRY_PASSWORD \
--namespace=default \
--namespace=agent-manager \
--dry-run=client -o yaml > k8s/acr-secret.yaml
echo ""
+1 -1
View File
@@ -13,7 +13,7 @@ fi
# 创建Secret
kubectl create secret generic kubeconfig-secret \
--from-file=config=$KUBECONFIG_FILE \
--namespace=default \
--namespace=agent-manager \
--dry-run=client -o yaml > k8s/kubeconfig-secret.yaml
echo "✅ Secret配置已生成: k8s/kubeconfig-secret.yaml"
+17 -12
View File
@@ -4,8 +4,8 @@
echo "开始部署Agent Manager (使用Kubeconfig Secret)..."
# 1. 创建命名空间
echo "1. 创建ai-agents命名空间..."
kubectl apply -f k8s/namespace.yaml
echo "1. 创建agent-manager命名空间..."
kubectl apply -f k8s/agent-manager-namespace.yaml
# 2. 创建ACR访问密钥
echo "2. 创建ACR访问密钥..."
@@ -23,26 +23,31 @@ if [ ! -f "k8s/kubeconfig-secret.yaml" ]; then
fi
kubectl apply -f k8s/kubeconfig-secret.yaml
# 4. 部署Agent Manager服务(使用kubeconfig)
echo "4. 部署Agent Manager..."
# 4. 创建运行时 Secret / ConfigMap
echo "4. 配置Secret和ConfigMap..."
kubectl apply -f k8s/agent-manager-secret.yaml
kubectl apply -f k8s/agent-manager-configmap.yaml
# 5. 部署Agent Manager服务(使用kubeconfig)
echo "5. 部署Agent Manager..."
kubectl apply -f k8s/deployment-with-kubeconfig.yaml
# 5. 等待部署完成
echo "5. 等待Pod就绪..."
kubectl wait --for=condition=ready pod -l app=agent-manager -n default --timeout=120s
# 6. 等待部署完成
echo "6. 等待Pod就绪..."
kubectl wait --for=condition=ready pod -l app=agent-manager -n agent-manager --timeout=120s
# 6. 显示服务状态
# 7. 显示服务状态
echo ""
echo "✅ 部署完成!"
echo ""
echo "服务状态:"
kubectl get pods -n default -l app=agent-manager
kubectl get pods -n agent-manager -l app=agent-manager
echo ""
echo "服务信息:"
kubectl get svc -n default -l app=agent-manager
kubectl get svc -n agent-manager -l app=agent-manager
echo ""
echo "查看日志:"
echo "kubectl logs -n default -l app=agent-manager -f"
echo "kubectl logs -n agent-manager deployment/agent-manager -f"
echo ""
echo "访问服务:"
echo "kubectl port-forward -n default svc/agent-manager 8000:8000"
echo "kubectl port-forward -n agent-manager svc/agent-manager 8000:8000"
+20 -14
View File
@@ -4,8 +4,8 @@
echo "开始部署Agent Manager (使用ServiceAccount + RBAC)..."
# 1. 创建命名空间
echo "1. 创建ai-agents命名空间..."
kubectl apply -f k8s/namespace.yaml
echo "1. 创建agent-manager命名空间..."
kubectl apply -f k8s/agent-manager-namespace.yaml
# 2. 创建ACR访问密钥
echo "2. 创建ACR访问密钥..."
@@ -17,28 +17,34 @@ kubectl apply -f k8s/acr-secret.yaml
# 3. 配置RBAC权限
echo "3. 配置RBAC权限..."
kubectl apply -f k8s/rbac.yaml
kubectl apply -f k8s/agent-manager-rbac.yaml
# 4. 部署Agent Manager服务
echo "4. 部署Agent Manager..."
kubectl apply -f k8s/deployment.yaml
# 4. 创建运行时 Secret / ConfigMap
echo "4. 配置Secret和ConfigMap..."
kubectl apply -f k8s/agent-manager-secret.yaml
kubectl apply -f k8s/agent-manager-configmap.yaml
# 5. 等待部署完成
echo "5. 等待Pod就绪..."
kubectl wait --for=condition=ready pod -l app=agent-manager -n default --timeout=120s
# 5. 部署Agent Manager服务
echo "5. 部署Agent Manager..."
kubectl apply -f k8s/agent-manager-deployment.yaml
kubectl apply -f k8s/agent-manager-service.yaml
# 6. 显示服务状态
# 6. 等待部署完成
echo "6. 等待Pod就绪..."
kubectl wait --for=condition=ready pod -l app=agent-manager -n agent-manager --timeout=120s
# 7. 显示服务状态
echo ""
echo "✅ 部署完成!"
echo ""
echo "服务状态:"
kubectl get pods -n default -l app=agent-manager
kubectl get pods -n agent-manager -l app=agent-manager
echo ""
echo "服务信息:"
kubectl get svc -n default -l app=agent-manager
kubectl get svc -n agent-manager -l app=agent-manager
echo ""
echo "查看日志:"
echo "kubectl logs -n default -l app=agent-manager -f"
echo "kubectl logs -n agent-manager deployment/agent-manager -f"
echo ""
echo "访问服务:"
echo "kubectl port-forward -n default svc/agent-manager 8000:8000"
echo "kubectl port-forward -n agent-manager svc/agent-manager 8000:80"
+6 -6
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-manager
namespace: default
namespace: agent-manager
labels:
app: agent-manager
spec:
@@ -22,14 +22,14 @@ spec:
- name: acr-secret
containers:
- name: agent-manager
image: agnettaiji.azurecr.io/agent-manager:latest
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604230600-arm64
imagePullPolicy: Always
ports:
- containerPort: 8000
name: http
env:
- name: NAMESPACE
value: "ai-agents"
value: "agent-manager"
- name: SERVICE_PORT
value: "8000"
- name: SERVICE_HOST
@@ -49,13 +49,13 @@ spec:
readOnly: true
livenessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 10
periodSeconds: 5
@@ -68,7 +68,7 @@ apiVersion: v1
kind: Service
metadata:
name: agent-manager
namespace: default
namespace: agent-manager
labels:
app: agent-manager
spec:
+3 -3
View File
@@ -20,7 +20,7 @@ spec:
- name: acr-secret
containers:
- name: agent-manager
image: agnettaiji.azurecr.io/agent-manager:latest
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604230600-arm64
imagePullPolicy: Always
ports:
- containerPort: 8000
@@ -49,13 +49,13 @@ spec:
# readOnly: true
livenessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /
path: /api/agent/health
port: 8000
initialDelaySeconds: 10
periodSeconds: 5
+1 -1
View File
@@ -4,4 +4,4 @@ data:
kind: Secret
metadata:
name: kubeconfig-secret
namespace: default
namespace: agent-manager
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: Secret
metadata:
name: kubeconfig-secret
namespace: default
namespace: agent-manager
type: Opaque
data:
config: |
+2 -2
View File
@@ -2,7 +2,7 @@ apiVersion: v1
kind: ServiceAccount
metadata:
name: agent-manager
namespace: default
namespace: agent-manager
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
@@ -33,7 +33,7 @@ metadata:
subjects:
- kind: ServiceAccount
name: agent-manager
namespace: default
namespace: agent-manager
roleRef:
kind: ClusterRole
name: agent-manager-role
+1185 -60
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
# NSG 规则检查结果
## 检查时间
2026-02-03
## NSG 规则详情
### 自定义规则(优先级从高到低)
1. **优先级 500**: `k8s-azure-lb_allow_IPv4_d39272f7f57ed937951650d93992ad77`
- 访问: Allow
- 协议: Tcp
- 源地址: Internet
- 目标端口: 80
- 目标地址: 所有
2. **优先级 501**: `k8s-azure-lb_allow_IPv4_556f7044ec033071ec0dfcf7cd85bc93`
- 访问: Allow
- 协议: Tcp
- 源地址: Internet
- 目标端口: 80, 443
- 目标地址: 40.65.173.54
### 默认规则
- **优先级 65000**: AllowVnetInBound - 允许 VNet 内部流量
- **优先级 65001**: AllowAzureLoadBalancerInBound - 允许 Azure LoadBalancer 流量
- **优先级 65500**: DenyAllInBound - 拒绝所有其他入站流量(但会被优先级更高的规则覆盖)
## 规则分析
✅ **NSG 规则配置正确**
- 没有更高优先级的 Deny 规则
- 规则 500 和 501 都允许从 Internet 访问 80/443 端口
- 规则优先级顺序正确(500 < 501 < 65000 < 65500)
## LoadBalancer 配置
### 健康检查探针
- **HTTP 探针**: `a2eecfe068cbe4c1b936d6b0880bee0b-TCP-80`
- 协议: Http
- 端口: 32519 (NodePort)
- 间隔: 5 秒
- 探测次数: 2
- 状态: Succeeded ✅
- **HTTPS 探针**: `a2eecfe068cbe4c1b936d6b0880bee0b-TCP-443`
- 协议: Https
- 端口: 31651 (NodePort)
- 间隔: 5 秒
- 探测次数: 2
- 状态: Succeeded ✅
### 后端池
- 后端池名称: `kubernetes`
- Endpoints: `10.224.0.37`, `10.224.0.66` (Ingress Controller Pods)
## 测试结果
### ✅ 成功的测试
- 从集群内部访问 `40.65.173.54:80` → 返回 308 重定向 ✅
- 从集群内部访问 `40.65.173.54:443` → TLS 握手成功 ✅
### ❌ 失败的测试
- 从外部网络访问 `40.65.173.54:80` → 连接超时 ❌
- 从外部网络访问 `40.65.173.54:443` → 连接超时 ❌
- 从外部网络访问 `test-openclaw-dns.taijiagnet.com` → 连接超时 ❌
## 问题分析
虽然 NSG 规则配置正确,但外部访问仍然超时。可能的原因:
1. **NSG 规则生效延迟**
- Azure NSG 规则可能需要几分钟才能完全生效
- 建议等待 5-10 分钟后再次测试
2. **外部网络到 Azure 的路径问题**
- 可能是 ISP 或网络运营商的限制
- 可能是地理位置或网络路由问题
- 建议从不同网络环境测试
3. **Azure 平台层面的限制**
- 可能有订阅级别的网络策略
- 可能有资源组级别的限制
- 需要检查 Azure Policy
4. **LoadBalancer 配置问题**
- 虽然健康检查显示 Succeeded,但可能后端实例状态异常
- 需要在 Azure Portal 中检查 LoadBalancer 的详细状态
## 建议操作
1. **等待规则生效**
- 等待 5-10 分钟让 NSG 规则完全生效
- 然后再次测试
2. **检查 LoadBalancer 状态**
- 在 Azure Portal 中打开 LoadBalancer `kubernetes`
- 检查"后端池"中的实例状态
- 检查"健康探测"的详细状态
3. **从不同网络测试**
- 使用手机热点
- 使用其他网络环境
- 排除本地网络问题
4. **检查 Azure Policy**
- 在 Azure Portal 中检查订阅的网络策略
- 检查资源组的网络策略
5. **联系 Azure 支持**
- 如果问题持续,建议打开 Azure 支持请求
- 提供 LoadBalancer 和 NSG 的详细信息
## 结论
NSG 规则配置**完全正确**,没有阻止访问的规则。问题可能在于:
- 规则生效延迟
- 外部网络到 Azure 的连接问题
- Azure 平台层面的其他限制
建议等待几分钟后再次测试,或从不同网络环境测试。
+177
View File
@@ -0,0 +1,177 @@
# OpenClaw Pod 访问问题诊断报告
## 问题描述
域名 `test-openclaw-dns.taijiagnet.com` 无法访问
## 诊断结果
### ✅ 正常的部分
1. **Pod 状态正常**
- Pod: `test-openclaw-dns-6c457fc9c8-p5pg5`
- 命名空间: `agent-test-openclaw-dns`
- 状态: `Running (2/2)` - 两个容器(gateway 和 dind)都在运行
- 容器就绪状态: `true true` - 两个容器都已就绪
2. **Pod 内部服务正常**
- 从 Pod 内部访问 `http://localhost:18789/health` 返回正常 HTML 响应
- 从 Pod 内部访问 Service `http://test-openclaw-dns-service:18789/health` 也正常
3. **Service 配置正确**
- Service: `test-openclaw-dns-service`
- 类型: `ClusterIP`
- 端口: `18789/TCP`
- Endpoints: `10.224.0.8:18789` ✅ 正确指向 Pod
4. **Ingress 配置正确**
- Ingress: `test-openclaw-dns-ingress`
- 域名: `test-openclaw-dns.taijiagnet.com`
- Backend: `test-openclaw-dns-service:18789 (10.224.0.8:18789)` ✅
- TLS: 已配置自签名证书
- Ingress IP: `40.65.173.54`
5. **DNS 解析正常**
- `test-openclaw-dns.taijiagnet.com` 正确解析到 `40.65.173.54`
- DNS 记录与 Ingress IP 一致
6. **Ingress Controller 运行正常**
- Ingress Controller Pods: 2 个都在运行
- Service: `ingress-nginx-controller` (LoadBalancer)
- LoadBalancer IP: `40.65.173.54`
- 端口映射: `80:32519/TCP, 443:31651/TCP`
### ❌ 问题所在
**外部网络访问被阻止**
从集群内部测试:
- ✅ Pod 内部访问正常
- ✅ Service 访问正常
- ❌ 外部访问 `40.65.173.54:80` 超时
- ❌ 外部访问 `40.65.173.54:443` 超时
**根本原因:Azure 网络安全组(NSG)或防火墙规则阻止了 80/443 端口**
## 解决方案
### 方案 1: 检查并更新 Azure 网络安全组(推荐)
1. 在 Azure Portal 中找到 AKS 集群的资源组
2. 找到与 LoadBalancer IP `40.65.173.54` 关联的网络安全组(NSG)
3. 添加入站规则:
- **端口 80 (HTTP)**: 允许来自 `*` 的流量
- **端口 443 (HTTPS)**: 允许来自 `*` 的流量
### 方案 2: 使用 Azure CLI 检查 NSG 规则
```bash
# 查找 LoadBalancer 关联的 NSG
az network lb list --query "[?frontendIpConfigurations[0].publicIpAddress=='40.65.173.54']" -o table
# 查找并更新 NSG 规则
az network nsg rule list --nsg-name <nsg-name> --resource-group <rg-name> -o table
az network nsg rule create \
--resource-group <rg-name> \
--nsg-name <nsg-name> \
--name AllowHTTP \
--priority 100 \
--direction Inbound \
--access Allow \
--protocol Tcp \
--destination-port-ranges 80
az network nsg rule create \
--resource-group <rg-name> \
--nsg-name <nsg-name> \
--name AllowHTTPS \
--priority 101 \
--direction Inbound \
--access Allow \
--protocol Tcp \
--destination-port-ranges 443
```
### 方案 3: 检查 AKS 节点池的 NSG
```bash
# 查找节点池的 NSG
az aks show --name <aks-cluster-name> --resource-group <rg-name> --query "agentPoolProfiles[0].vnetSubnetId" -o tsv
# 然后查找该子网的 NSG 并更新规则
```
## 其他发现
### 配置版本警告(非关键)
- 日志显示: `Config was last written by a newer OpenClaw (2026.2.3); current version is 2026.1.30`
- 这是配置版本不匹配的警告,不影响功能,但建议更新镜像版本
### Readiness Probe 早期失败(已恢复)
- 在 Pod 启动初期有 readiness probe 失败
- 现在已经恢复正常,容器状态为 `ready`
## 验证步骤
修复 NSG 规则后,验证访问:
```bash
# 测试 HTTP 访问
curl -v http://test-openclaw-dns.taijiagnet.com/health
# 测试 HTTPS 访问(忽略自签名证书警告)
curl -k -v https://test-openclaw-dns.taijiagnet.com/health
```
## 重要发现
### ✅ 从集群内部访问成功
从 Kubernetes 集群内部测试访问 `40.65.173.54:80` **成功**,返回了 308 重定向响应。这说明:
- LoadBalancer 配置正确 ✅
- Ingress Controller 工作正常 ✅
- 路由规则正确 ✅
- NSG 规则对集群内部生效 ✅
### ❌ 从外部网络访问失败
从外部网络访问 `40.65.173.54:80` 和 `443` 端口超时。这说明问题在于:
- **外部网络到 Azure LoadBalancer 的路径被阻止**
## 总结
**问题类型**: 外部网络到 Azure 的网络连接问题
**已确认正常的部分**:
- ✅ DNS 绑定正常(DNS 解析正确)
- ✅ Pod 正常运行(2/2 容器就绪)
- ✅ Ingress 配置正确(路由规则正确)
- ✅ Service 配置正确(Endpoints 正确)
- ✅ NSG 规则已配置(允许 80/443 端口)
- ✅ LoadBalancer 配置正确(从集群内部访问成功)
**问题所在**:
- ❌ 外部网络无法连接到 Azure LoadBalancer IP `40.65.173.54`
**可能的原因**:
1. **NSG 规则可能只对集群内部生效**
- 虽然规则显示 `sourceAddressPrefix: Internet`,但可能实际只允许集群内部访问
- 需要检查是否有其他限制
2. **Azure 订阅或资源组级别的网络策略**
- 可能有订阅级别的网络限制
- 可能有资源组的网络策略
3. **外部网络到 Azure 的路径问题**
- 可能是 ISP 或网络运营商的问题
- 可能是地理位置限制
4. **LoadBalancer 的源地址限制**
- 虽然检查显示 `loadBalancerSourceRanges` 为空,但可能在其他地方有限制
**建议操作**:
1. ✅ 已确认 NSG 规则存在(优先级 501,允许 Internet 访问 80/443)
2. ⏳ 检查是否有更高优先级的 Deny 规则
3. ⏳ 在 Azure Portal 中检查 LoadBalancer 的详细配置
4. ⏳ 尝试从不同的外部网络测试(排除本地网络问题)
5. ⏳ 联系 Azure 支持检查是否有平台级别的限制
+133
View File
@@ -0,0 +1,133 @@
# OpenClaw 网络访问问题详细检查报告
## 检查时间
2026-02-03
## 检查结果
### ✅ 已确认正常的部分
1. **NSG 规则已配置**
- 规则名称: `k8s-azure-lb_allow_IPv4_556f7044ec033071ec0dfcf7cd85bc93`
- 优先级: 501
- 访问: Allow
- 协议: Tcp
- 源地址: Internet
- 目标端口: 80, 443
- 目标地址: 40.65.173.54 ✅
2. **LoadBalancer 配置正常**
- Public IP: `40.65.173.54`
- SKU: Standard
- 状态: Succeeded
- Zones: 1, 2, 3
3. **Ingress Controller 运行正常**
- Pods: 2 个都在运行
- 监听端口: 80, 443 ✅
- Service Endpoints: 正常
4. **Kubernetes 资源正常**
- Pod: Running (2/2)
- Service: Endpoints 正确
- Ingress: 配置正确,已同步
### ❌ 问题现象
从外部网络访问 `40.65.173.54:80` 和 `443` 端口超时
### 可能的原因
虽然 NSG 规则已配置,但可能存在以下情况:
1. **规则优先级问题**
- 可能有更高优先级的 Deny 规则覆盖了 Allow 规则
- 需要检查所有 Inbound 规则的优先级顺序
2. **网络路由问题**
- 可能有路由表规则影响流量
- 需要检查 VNet 的路由配置
3. **LoadBalancer 健康检查问题**
- LoadBalancer 可能认为后端不健康
- 需要检查 LoadBalancer 的健康检查配置
4. **Azure 平台层面的限制**
- 可能有订阅级别的网络策略
- 可能有其他安全策略影响
## 建议的排查步骤
### 步骤 1: 检查所有 NSG 规则的优先级
```bash
az network nsg rule list \
--nsg-name aks-agentpool-50066007-nsg \
--resource-group mc_taiji-ai-pda_taiji-ai-pda_southeastasia \
--query "[?direction=='Inbound'].{name:name, priority:priority, access:access, destinationPortRanges:destinationPortRanges}" \
-o table
```
**重要**: 检查是否有优先级 < 501 的 Deny 规则
### 步骤 2: 检查 LoadBalancer 健康检查
在 Azure Portal 中:
1. 找到 LoadBalancer: `kubernetes`
2. 检查健康探测(Health Probes)状态
3. 检查后端池(Backend Pools)中的实例状态
### 步骤 3: 检查 VNet 路由表
```bash
az network route-table list --query "[].{name:name, resourceGroup:resourceGroup}" -o table
```
### 步骤 4: 从 Azure 内部测试
尝试从 Azure 内部的另一个资源(如另一个 VM 或 Container Instance)测试访问
### 步骤 5: 检查 Azure 订阅的网络限制
在 Azure Portal 中检查:
- 订阅的网络策略
- 资源组的网络策略
- 是否有 Azure Policy 限制网络访问
## 临时解决方案
如果急需访问,可以考虑:
1. **使用 NodePort 直接访问**
```bash
# 获取 NodePort
kubectl get svc -n ingress-nginx ingress-nginx-controller
# 然后通过节点的公网 IP + NodePort 访问
```
2. **使用 kubectl port-forward**
```bash
kubectl port-forward -n ingress-nginx svc/ingress-nginx-controller 8080:80
# 然后访问 http://localhost:8080
```
3. **检查是否有其他 LoadBalancer**
- 可能需要在 Azure Portal 中检查是否有多个 LoadBalancer
- 确认使用的是正确的 LoadBalancer
## 下一步行动
1. ✅ 已确认 NSG 规则存在
2. ⏳ 需要检查规则优先级顺序
3. ⏳ 需要检查 LoadBalancer 健康检查状态
4. ⏳ 需要从 Azure 内部测试访问
5. ⏳ 需要检查是否有其他网络限制
## 联系信息
如果问题持续,建议:
1. 在 Azure Portal 中打开支持请求
2. 提供 LoadBalancer 和 NSG 的详细信息
3. 说明已配置的 NSG 规则但仍然无法访问
+17 -6
View File
@@ -90,20 +90,31 @@ build_image() {
echo "镜像名称: ${FULL_IMAGE_NAME}"
# 使用 buildx 支持多架构构建
# 检查并设置 buildx
if ! docker buildx version &> /dev/null; then
print_warning "docker buildx 未启用,尝试启用..."
docker buildx create --use
print_error "docker buildx 未安装,请先安装 Docker Buildx"
exit 1
fi
# 构建镜像
# 创建并使用 buildx builder(如果不存在)
BUILDER_NAME="arm64-builder"
if ! docker buildx inspect ${BUILDER_NAME} &> /dev/null; then
print_step "创建 buildx builder: ${BUILDER_NAME}"
docker buildx create --name ${BUILDER_NAME} --use --driver docker-container
docker buildx inspect --bootstrap
else
docker buildx use ${BUILDER_NAME}
fi
# 构建 ARM64 镜像并推送到 ACR
print_step "开始构建 ARM64 镜像..."
if docker buildx build \
--platform linux/arm64 \
-f Dockerfile.arm64 \
-f Dockerfile \
-t ${FULL_IMAGE_NAME} \
--push \
.; then
print_success "镜像构建并推送成功"
print_success "ARM64 镜像构建并推送成功: ${FULL_IMAGE_NAME}"
else
print_error "镜像构建失败"
exit 1
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""End-to-end sub-mode Runtime check for a complete generated project.
This script is intentionally outside the normal unit-test suite because it
requires a live agent-manager Runtime, Kubernetes, and a model gateway.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_OBJECTIVE = """Return ONLY valid JSON:
{"files":[{"path":"...","content":"..."}],"run_tests":"python -m unittest discover -s tests -v","smoke_test":"python -m textstats_cli samples/example.txt --json"}.
Build a complete, compact Python stdlib project named textstats_cli.
Include exactly these files:
- pyproject.toml
- README.md
- textstats_cli/__main__.py
- textstats_cli/core.py
- tests/test_core.py
- samples/example.txt
Features:
- CLI accepts a text file path.
- --json outputs JSON.
- Default output is human readable.
- Report line count, word count, character count, top 5 words excluding common stopwords, and estimated reading time.
- Tests must cover counting, stopword filtering, JSON-safe result shape, and missing-file error handling.
Constraints:
- Use only the Python standard library.
- Keep the project small enough for one response.
- No placeholders.
- No markdown fences.
- No prose outside the JSON object.
"""
TERMINAL_STATUSES = {"completed", "failed", "stopped"}
def http_json(method: str, url: str, payload: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> Any:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request_headers = {"Content-Type": "application/json", **(headers or {})}
request = urllib.request.Request(url, data=data, headers=request_headers, method=method)
try:
with urllib.request.urlopen(request, timeout=60) as response:
return json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"{method} {url} failed with HTTP {exc.code}: {body}") from exc
def http_bytes(url: str) -> bytes:
with urllib.request.urlopen(url, timeout=120) as response:
return response.read()
def extract_json_object(text: str) -> dict[str, Any]:
raw = text.strip()
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", raw, re.S)
if fenced:
raw = fenced.group(1)
else:
start = raw.find("{")
end = raw.rfind("}")
if start < 0 or end <= start:
raise ValueError("artifact does not contain a JSON object")
raw = raw[start : end + 1]
return json.loads(raw)
def safe_write_project(project_dir: Path, files: list[dict[str, Any]]) -> None:
project_root = project_dir.resolve()
for item in files:
relative_path = item.get("path")
content = item.get("content")
if not isinstance(relative_path, str) or not relative_path:
raise ValueError(f"invalid file path in artifact: {item!r}")
if not isinstance(content, str):
raise ValueError(f"invalid content for {relative_path}")
target = (project_root / relative_path).resolve()
if project_root not in target.parents and target != project_root:
raise ValueError(f"artifact attempted to write outside project: {relative_path}")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
def run_command(command: str, cwd: Path) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
shim_dir = None
if shutil.which("python") is None:
shim_dir = Path(tempfile.mkdtemp(prefix="heicode-python-shim-"))
(shim_dir / "python").symlink_to(sys.executable)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
return subprocess.run(
command,
cwd=str(cwd),
env=env,
shell=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=120,
check=False,
)
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
return {
"orchestration_plan": {
"sub_mode": "agile",
"objective": args.objective,
"user_context": {"user_id": args.user_id},
"agents": [
{
"role": "backend",
"template": "a2a_litellm_agent",
"model": args.model,
"capabilities": ["code", "test"],
}
],
"agile_context": {"max_iterations": 1, "stage": "development"},
"budget": {"max_duration_sec": args.timeout_seconds, "max_tokens": args.max_tokens},
"billing_context": {
"provider": "newapi",
"default_model_id": args.model,
"model_gateway_url": args.model_gateway_url,
"api_format": args.api_format,
"stream": args.stream,
"timeout_sec": args.model_timeout_seconds,
"max_tokens": args.max_tokens,
},
"metadata": {"correlation_id": args.correlation_id},
},
"callback": {"url": args.callback_url, "method": "POST"},
}
def poll_swarm(base_url: str, swarm_id: str, timeout_seconds: int, poll_interval: int) -> dict[str, Any]:
deadline = time.time() + timeout_seconds
last_line = None
while time.time() < deadline:
status = http_json("GET", f"{base_url}/api/swarms/{swarm_id}")
line = {
"status": status.get("status"),
"phase": status.get("phase"),
"progress": status.get("progress"),
"artifact_count": len(status.get("artifacts") or []),
"tokens_used": (status.get("metrics") or {}).get("tokens_used"),
}
if line != last_line:
print("status:", json.dumps(line, ensure_ascii=False))
last_line = line
if status.get("status") in TERMINAL_STATUSES:
return status
time.sleep(poll_interval)
raise TimeoutError(f"swarm {swarm_id} did not finish within {timeout_seconds}s")
def assert_runtime_observability(base_url: str, swarm_id: str, status: dict[str, Any]) -> None:
metrics = status.get("metrics") or {}
tokens_used = int(metrics.get("tokens_used") or 0)
if tokens_used <= 0:
raise AssertionError(f"Runtime tokens_used must be > 0, got {tokens_used}")
logs = http_json("GET", f"{base_url}/api/swarms/{swarm_id}/logs")
request_ids: list[str] = []
usage_totals: list[int] = []
for agent in logs.get("agents") or []:
for message in agent.get("messages") or []:
if message.get("newapi_request_id"):
request_ids.append(message["newapi_request_id"])
usage = message.get("model_usage") or {}
if usage.get("total_tokens"):
usage_totals.append(int(usage["total_tokens"]))
if not request_ids:
raise AssertionError("Runtime logs must include at least one NewAPI request_id")
if not usage_totals:
raise AssertionError("Runtime logs must include model usage with total_tokens")
print("observability:", json.dumps({"request_ids": request_ids, "usage_totals": usage_totals}, ensure_ascii=False))
def run_project_validation(base_url: str, status: dict[str, Any], output_dir: Path) -> None:
artifacts = status.get("artifacts") or []
if status.get("status") != "completed":
raise AssertionError(f"swarm did not complete: {status.get('error_message')}")
if not artifacts:
raise AssertionError("completed swarm returned no artifacts")
artifact = artifacts[0]
download_path = (artifact.get("metadata") or {}).get("download_path")
if not download_path:
raise AssertionError("artifact metadata is missing download_path")
artifact_bytes = http_bytes(f"{base_url}{download_path}")
artifact_hash = "sha256:" + hashlib.sha256(artifact_bytes).hexdigest()
expected_hash = (artifact.get("metadata") or {}).get("content_hash")
if expected_hash and artifact_hash != expected_hash:
raise AssertionError(f"artifact hash mismatch: expected {expected_hash}, got {artifact_hash}")
artifact_text = artifact_bytes.decode("utf-8")
artifact_json = extract_json_object(artifact_text)
files = artifact_json.get("files")
if not isinstance(files, list) or len(files) < 5:
raise AssertionError("artifact must contain a multi-file project")
project_dir = output_dir / "project"
if project_dir.exists():
shutil.rmtree(project_dir)
project_dir.mkdir(parents=True)
safe_write_project(project_dir, files)
required_paths = {
"pyproject.toml",
"README.md",
"textstats_cli/__main__.py",
"textstats_cli/core.py",
"tests/test_core.py",
"samples/example.txt",
}
actual_paths = {str(path.relative_to(project_dir)) for path in project_dir.rglob("*") if path.is_file()}
missing = sorted(required_paths - actual_paths)
if missing:
raise AssertionError(f"generated project missing required files: {missing}")
for label, command in (
("run_tests", artifact_json.get("run_tests")),
("smoke_test", artifact_json.get("smoke_test")),
):
if not isinstance(command, str) or not command.strip():
raise AssertionError(f"artifact missing {label}")
result = run_command(command, project_dir)
print(f"{label}: {command}")
print(result.stdout)
if result.returncode != 0:
raise AssertionError(f"{label} failed with exit code {result.returncode}")
print("artifact:", json.dumps({
"artifact_id": artifact.get("artifact_id"),
"uri": artifact.get("uri"),
"size_bytes": artifact.get("size_bytes"),
"content_hash": artifact_hash,
"project_dir": str(project_dir),
}, ensure_ascii=False))
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default=os.getenv("AGENT_MANAGER_URL", "http://127.0.0.1:8000"))
parser.add_argument("--model-gateway-url", default=os.getenv("HEICODE_NEWAPI_BASE_URL", "https://code.xinghanlab.com/v1"))
parser.add_argument("--model", default=os.getenv("HEICODE_E2E_MODEL", "gpt-5.4"))
parser.add_argument("--api-format", default=os.getenv("HEICODE_E2E_API_FORMAT", "openai_chat"))
parser.add_argument("--stream", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--max-tokens", type=int, default=int(os.getenv("HEICODE_E2E_MAX_TOKENS", "6000")))
parser.add_argument("--timeout-seconds", type=int, default=int(os.getenv("HEICODE_E2E_TIMEOUT_SECONDS", "1200")))
parser.add_argument("--model-timeout-seconds", type=int, default=int(os.getenv("HEICODE_E2E_MODEL_TIMEOUT_SECONDS", "600")))
parser.add_argument("--poll-interval", type=int, default=10)
parser.add_argument("--user-id", default="heicode-complete-project-e2e")
parser.add_argument("--callback-url", default="http://127.0.0.1:9/heicode-callback")
parser.add_argument("--correlation-id", default=f"heicode-complete-project-e2e-{int(time.time())}")
parser.add_argument("--idempotency-key", default=f"complete-project-e2e-{int(time.time())}")
parser.add_argument("--objective", default=DEFAULT_OBJECTIVE)
parser.add_argument("--output-dir", type=Path, default=None)
return parser.parse_args()
def main() -> int:
args = parse_args()
base_url = args.base_url.rstrip("/")
output_dir = args.output_dir or Path(tempfile.mkdtemp(prefix="heicode-complete-project-e2e-"))
output_dir.mkdir(parents=True, exist_ok=True)
payload = build_payload(args)
created = http_json(
"POST",
f"{base_url}/api/swarms",
payload,
headers={"X-Idempotency-Key": args.idempotency_key},
)
swarm_id = created["swarm_id"]
print("created:", json.dumps({"swarm_id": swarm_id, "output_dir": str(output_dir)}, ensure_ascii=False))
status = poll_swarm(base_url, swarm_id, args.timeout_seconds, args.poll_interval)
run_project_validation(base_url, status, output_dir)
assert_runtime_observability(base_url, swarm_id, status)
print("PASS: complete project E2E succeeded")
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"FAIL: {exc}", file=sys.stderr)
raise SystemExit(1)
+31 -1
View File
@@ -132,7 +132,37 @@ DEFAULT_TEMPLATES = {
"image": "agnettaiji.azurecr.io/ai-agents/a2a-litellm-agent:latest",
"port": 8000,
"agent_framework": "a2a",
"env_requirements": {},
"env_requirements": {
"optional": {
"AGENT_ACCESS_TOKEN": "客户端访问令牌;若设置,A2A 请求必须携带 X-Agent-Access-Token",
"HEICODE_AGENT_ID": "上层系统分配的 agent_id,用于 health / agent card 暴露与联调排查",
}
},
},
"coding_a2a_agent": {
"display_name": "Coding A2A Agent",
"description": "Claude Code 风格编程 Agent(Pydantic AI + A2A)",
"image": "agnettaiji.azurecr.io/ai-agents/coding-a2a-agent:latest",
"port": 8000,
"agent_framework": "a2a",
"env_requirements": {
"required": {
"OPENAI_BASE_URL": "LiteLLM / OpenAI 兼容网关地址",
},
"optional": {
"OPENAI_API_KEY": "模型 API Key",
"LITELLM_API_KEY": "模型 API Key(兼容变量)",
"MODEL_NAME": "模型名称",
"LITELLM_MODEL": "模型名称(兼容变量)",
"WORK_DIR": "工作区目录,默认 /workspace",
"AGENT_ROLE_NAME": "启动时指定角色名称,例如 backend / reviewer / planner",
"AGENT_INSTRUCTION_TEXT": "启动时注入的角色/行为说明文本,支持类似 AGENTS.md / claude.md 内容",
"AGENT_INSTRUCTION_FILE": "启动时读取的角色说明文件路径,内容会并入系统提示词",
"AGENT_ACCESS_TOKEN": "客户端访问令牌;若设置,A2A 请求必须携带 X-Agent-Access-Token",
"HEICODE_AGENT_ID": "上层系统分配的 agent_id,用于 health / agent card 暴露与联调排查",
"SERVICE_PORT": "服务端口,默认 8000",
},
},
},
"code_ai_agent": {
"display_name": "Code AI Agent",
+114
View File
@@ -0,0 +1,114 @@
"""Contract tests for the sub-mode runtime API surfaces."""
from pathlib import Path
from api.status_projection import RuntimeDisplayStatus, project_deployment_status, project_runtime_run_status
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_primary_and_compat_runtime_routes_are_registered():
"""The new agent surface and both compatibility surfaces must coexist."""
app_source = _read("app.py")
agent_router_source = _read("api/agent/router.py")
callbacks_source = _read("api/agnet/callbacks.py")
swarm_router_source = _read("api/swarm/router.py")
assert "from api.agent.router import router as agent_router" in app_source
assert 'prefix="/api/agent"' in agent_router_source
assert 'prefix="/sub-agile"' in agent_router_source
assert '@router.post("/runtime-events")' in callbacks_source
assert '@compat_router.post("/swarm-events")' in callbacks_source
assert '@router.post("/deployments/{deployment_id}/artifact-edits"' in _read("api/agnet/deployments.py")
assert 'prefix="/api/swarms"' in swarm_router_source
assert '@swarms_router.post("/{swarm_id}/approvals/{approval_id}")' in swarm_router_source
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/manifest")' in swarm_router_source
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/archive.zip")' in swarm_router_source
assert '@swarms_router.get("/{swarm_id}/artifacts/{artifact_id}/files/{file_path:path}")' in swarm_router_source
def test_projected_statuses_cover_manager_facing_contract():
"""Projected deployment statuses should match the Manager contract."""
assert project_deployment_status("pending") == RuntimeDisplayStatus.ACCEPTED.value
assert (
project_deployment_status(
"running",
events=[{"event_type": "approval.requested", "payload": {"approval_id": "appr_1"}}],
)
== RuntimeDisplayStatus.WAITING_APPROVAL.value
)
assert (
project_deployment_status(
"running",
phase="done",
events=[{"event_type": "task.completed", "payload": {"status": "completed"}}],
)
== RuntimeDisplayStatus.COMPLETED.value
)
assert (
project_deployment_status(
"running",
events=[{"event_type": "task.failed", "payload": {"status": "failed"}}],
)
== RuntimeDisplayStatus.FAILED.value
)
assert project_deployment_status("stopped") == RuntimeDisplayStatus.STOPPED.value
def test_runtime_run_projection_remains_sub_mode_compatible():
"""Legacy /api/swarms runs should expose the shared projected status set."""
assert project_runtime_run_status("initializing") == RuntimeDisplayStatus.ACCEPTED.value
assert project_runtime_run_status("running") == RuntimeDisplayStatus.RUNNING.value
assert project_runtime_run_status("completed") == RuntimeDisplayStatus.COMPLETED.value
assert project_runtime_run_status("failed") == RuntimeDisplayStatus.FAILED.value
assert project_runtime_run_status("stopped") == RuntimeDisplayStatus.STOPPED.value
def test_project_folder_contract_is_documented_and_flagged():
"""Structural code artifacts should be documented and tagged distinctly."""
doc_source = _read("docs/HEICODE_SUB_MODE_RUNTIME_INTEGRATION.md")
orchestrator_source = _read("api/swarm/orchestrator.py")
swarm_router_source = _read("api/swarm/router.py")
assert "project_folder artifact(结构性代码强制要求)" in doc_source
assert "manifest_uri" in doc_source
assert "archive_uri" in doc_source
assert "files/{path}" in doc_source
assert '"artifact_layout": "project_folder"' in orchestrator_source
assert '"primary_read_path": "manifest"' in orchestrator_source
assert '"summary_only": True' in swarm_router_source
def test_runtime_rich_workflow_contract_is_present():
"""Workflow/work outputs should expose richer runtime fields."""
swarm_models_source = _read("api/swarm/models.py")
swarm_router_source = _read("api/swarm/router.py")
orchestrator_source = _read("api/swarm/orchestrator.py")
assert "tokens: int = 0" in swarm_models_source
assert "tools: int = 0" in swarm_models_source
assert "elapsed_seconds: int = 0" in swarm_models_source
assert "artifact_ids: List[str] = Field(default_factory=list)" in swarm_models_source
assert "class SwarmPhaseInfo" in swarm_models_source
assert "phases: List[SwarmPhaseInfo]" in swarm_models_source
assert "source_agent_role" in orchestrator_source
assert "project_revision" in orchestrator_source
assert "delivery_ref" in orchestrator_source
assert "git_ref" in orchestrator_source
assert "@swarms_router.post(\"/{swarm_id}/artifact-edits\"" in swarm_router_source
callbacks_source = _read("api/agnet/callbacks.py")
assert "artifact.local_edit_applied" in callbacks_source
assert "artifact.local_edit_conflict" in callbacks_source
assert '"project_folder"' in callbacks_source
deployments_source = _read("api/agnet/deployments.py")
assert "_emit_artifact_edit_event(swarm, request, \"artifact.local_edit_conflict\")" in deployments_source
assert "mode: Optional[str] = None" in swarm_models_source
assert "_extract_git_project_context" in swarm_router_source
assert "git_binding_id" in swarm_router_source
assert "allowed_paths" in swarm_router_source
+118
View File
@@ -0,0 +1,118 @@
"""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
def test_a2a_servers_enforce_agent_access_token_contract():
"""A2A servers should support local X-Agent-Access-Token validation."""
coding_server_source = _read("agent_templates/agents/coding_a2a_agent/a2a_server.py")
litellm_server_source = _read("agent_templates/agents/a2a_litellm_agent/a2a_server.py")
for source in (coding_server_source, litellm_server_source):
assert 'AGENT_ACCESS_TOKEN = os.getenv("AGENT_ACCESS_TOKEN", "")' in source
assert 'AGENT_ACCESS_HEADER = "X-Agent-Access-Token"' in source
assert "secrets.compare_digest" in source
assert 'status_code=401' in source
assert 'status_code=403' in source
assert 'request.headers.get(AGENT_ACCESS_HEADER, "")' in source
def test_a2a_docs_describe_agent_access_token_header():
"""Template-agent docs should explain the local access-token authentication flow."""
coding_doc_source = _read("docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md")
integration_doc_source = _read("docs/HEICODE_API_INTEGRATION.md")
assert "AGENT_ACCESS_TOKEN" in coding_doc_source
assert "X-Agent-Access-Token" in coding_doc_source
assert "缺少请求头时返回 `401`" in coding_doc_source
assert "请求头不匹配时返回 `403`" in coding_doc_source
assert "客户端直连鉴权" in integration_doc_source
assert "X-Agent-Access-Token == AGENT_ACCESS_TOKEN" in integration_doc_source
def test_dedicated_template_agent_contract_doc_exists():
"""A standalone HM-facing template-agent contract doc should exist."""
doc_source = _read("docs/HEICODE_TEMPLATE_AGENT_RUNTIME_CONTRACT.md")
assert "# Heicode Template Agent Runtime 对接文档" in doc_source
assert "## 3. 状态接口" in doc_source
assert "GET /agents/{agent_name}" in doc_source
assert "GET /agents/{agent_name}/status" in doc_source
assert "POST /agents/{agent_name}/stop" in doc_source
assert "DELETE /agents/{agent_name}" in doc_source
assert "X-Agent-Access-Token" in doc_source
def test_template_manager_exposes_agent_access_token_envs():
"""Template definitions should advertise the HM access-token envs explicitly."""
template_source = _read("template_manager.py")
assert '"a2a_litellm_agent": {' in template_source
assert '"coding_a2a_agent": {' in template_source
assert '"AGENT_ACCESS_TOKEN": "客户端访问令牌;若设置,A2A 请求必须携带 X-Agent-Access-Token"' in template_source
assert '"HEICODE_AGENT_ID": "上层系统分配的 agent_id,用于 health / agent card 暴露与联调排查"' in template_source