Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55780e652b | ||
|
|
3dd7da0b15 | ||
|
|
8e9032e74a | ||
|
|
f6851c9680 | ||
|
|
e0bf45db2f | ||
|
|
2e5321e16f | ||
|
|
08ac5067be | ||
|
|
f4d7b9a5b1 | ||
|
|
253ea923cd | ||
|
|
880139ab3e | ||
|
|
bf1508e49c | ||
|
|
6de433d92b | ||
|
|
504d9a1ab0 | ||
|
|
aae209574a | ||
|
|
f306f2700b | ||
|
|
1b49887819 | ||
|
|
9bc172b22a | ||
|
|
487bff7e17 | ||
|
|
5e248a09bb | ||
|
|
532dca13f4 | ||
|
|
ac7d828e80 | ||
|
|
17937ecfd3 | ||
|
|
2e07f40cd4 | ||
|
|
e3849bd538 | ||
|
|
dd2fd11f75 | ||
|
|
0ddf2681ec | ||
|
|
749e97cbe2 |
@@ -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` - 快速构建和部署脚本
|
||||
|
||||
|
||||
@@ -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
@@ -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 或商业证书。
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
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,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Primary agent API module for Heicode sub-mode runtime."""
|
||||
|
||||
from .router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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 @@
|
||||
"""Agnet API module for Heicode integration."""
|
||||
"""Legacy agnet compatibility module for Heicode sub-mode runtime APIs."""
|
||||
|
||||
+173
-15
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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://"):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"}:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Executable
+79
@@ -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}"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 ""
|
||||
|
||||
@@ -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 仍会启动,只是在调用对应资源工具时返回未配置提示
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -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`。
|
||||
|
||||
Executable
+90
@@ -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
@@ -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
@@ -4,5 +4,5 @@ data:
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: acr-secret
|
||||
namespace: default
|
||||
namespace: agent-manager
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -4,4 +4,4 @@ data:
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: kubeconfig-secret
|
||||
namespace: default
|
||||
namespace: agent-manager
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: kubeconfig-secret
|
||||
namespace: default
|
||||
namespace: agent-manager
|
||||
type: Opaque
|
||||
data:
|
||||
config: |
|
||||
|
||||
+2
-2
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 平台层面的其他限制
|
||||
|
||||
建议等待几分钟后再次测试,或从不同网络环境测试。
|
||||
|
||||
|
||||
@@ -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 支持检查是否有平台级别的限制
|
||||
|
||||
@@ -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 规则但仍然无法访问
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+311
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user