Add coding A2A agent template and docs
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
# 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` | 启动时读取角色说明文件路径,文件内容会并入系统提示词 |
|
||||
| `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 示例
|
||||
|
||||
`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,298 @@
|
||||
"""
|
||||
A2A server for the coding agent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
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
|
||||
|
||||
from coding_a2a_agent.common.agent_callback_utils import AgentCallbackHandler, CallbackContextManager
|
||||
from coding_a2a_agent.agent import CodingA2ARuntime
|
||||
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", "")
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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 _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,
|
||||
}
|
||||
|
||||
@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,
|
||||
"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),
|
||||
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):
|
||||
rpc_request = A2ARequest(**(await request.json()))
|
||||
if rpc_request.method != "message/send":
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": rpc_request.id, "error": {"code": -32601, "message": 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):
|
||||
rpc_request = A2ARequest(**(await request.json()))
|
||||
return await self._handle_message_stream(rpc_request)
|
||||
|
||||
@app.get("/tasks/{task_id}")
|
||||
async def get_task(task_id: str):
|
||||
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:
|
||||
params = request.params or {}
|
||||
message_text = self._extract_message_text(params.get("message", {}))
|
||||
if not message_text:
|
||||
return JSONResponse(
|
||||
{"jsonrpc": "2.0", "id": request.id, "error": {"code": -32602, "message": "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
|
||||
|
||||
try:
|
||||
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 Exception as exc:
|
||||
task.status = A2ATaskStatus(state="failed", message=str(exc))
|
||||
self.tasks[task_id] = task
|
||||
return JSONResponse(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.id,
|
||||
"error": {"code": -32000, "message": f"Agent error: {exc}"},
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_message_stream(self, request: A2ARequest) -> StreamingResponse:
|
||||
params = request.params or {}
|
||||
message_text = self._extract_message_text(params.get("message", {}))
|
||||
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
|
||||
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 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,417 @@
|
||||
"""
|
||||
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 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)
|
||||
|
||||
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 _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,129 @@
|
||||
import unittest
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from coding_a2a_agent.config import AgentMetadata, CodingRequestConfig, GitProvider
|
||||
from coding_a2a_agent.resources import (
|
||||
build_authenticated_repo_url,
|
||||
detect_git_provider,
|
||||
ensure_model_prefix,
|
||||
safe_workspace_path,
|
||||
)
|
||||
|
||||
|
||||
class CodingA2AHelperTests(unittest.TestCase):
|
||||
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")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
+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()
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `OPENAI_API_KEY` 当前建议在启动时传入
|
||||
- 当前实测可用模型示例是 `gpt-5.4`
|
||||
- 返回中会带 `namespace`、`pod_ip`、`access_info.external_ip`、`access_info.domain`
|
||||
|
||||
## 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"],
|
||||
"timestamp": "2026-06-04T05:04:22.760314Z"
|
||||
}
|
||||
```
|
||||
|
||||
## 7. A2A 调用方式
|
||||
|
||||
### 7.1 同步调用
|
||||
|
||||
```http
|
||||
POST /message/send
|
||||
```
|
||||
|
||||
最小调用示例:
|
||||
|
||||
```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`。
|
||||
|
||||
## 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 仍会启动,只是在调用对应资源工具时返回未配置提示
|
||||
@@ -31,7 +31,7 @@ spec:
|
||||
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604014451-arm64
|
||||
imagePullPolicy: Always
|
||||
|
||||
ports:
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604014451-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ spec:
|
||||
- name: acr-secret
|
||||
containers:
|
||||
- name: agent-manager
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260603140537-arm64
|
||||
image: agnettaiji.azurecr.io/ai-agents/agent-manager:heicode-v2-runtime-20260604014451-arm64
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8000
|
||||
|
||||
+26
-1
@@ -518,6 +518,7 @@ IP.1 = 127.0.0.1
|
||||
"azure_blob_agent_mcp": 8000,
|
||||
"azure_blob_agent_a2a": 8000,
|
||||
"a2a_litellm_agent": 8000,
|
||||
"coding_a2a_agent": 8000,
|
||||
"code_ai_agent": 8000,
|
||||
"facebook_agent": 8000,
|
||||
"media_downloader": 8000,
|
||||
@@ -683,6 +684,24 @@ IP.1 = 127.0.0.1
|
||||
"SERVICE_HOST": "HTTP服务监听地址,默认 0.0.0.0"
|
||||
}
|
||||
},
|
||||
"coding_a2a_agent": {
|
||||
"required": {
|
||||
"OPENAI_BASE_URL": "LiteLLM / OpenAI 兼容网关地址,如 https://gateway.example.com/v1"
|
||||
},
|
||||
"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": "启动时读取的角色说明文件路径,内容会并入系统提示词",
|
||||
"SERVICE_PORT": "HTTP服务端口,默认 8000",
|
||||
"SERVICE_HOST": "HTTP服务监听地址,默认 0.0.0.0"
|
||||
},
|
||||
"description": "Claude Code 风格编程 Agent,使用 Pydantic AI 作为核心并通过 A2A 协议对外提供服务"
|
||||
},
|
||||
"code_ai_agent": {
|
||||
"required": {
|
||||
"LLM_BASE_URL": "LLM 服务地址,如 https://api.openai.com/v1",
|
||||
@@ -905,6 +924,11 @@ IP.1 = 127.0.0.1
|
||||
"code_ai_agent": {
|
||||
"OPENAI_BASE_URL": "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1",
|
||||
"LITELLM_MODEL": "taiji/gpt-4o-mini"
|
||||
},
|
||||
"coding_a2a_agent": {
|
||||
"OPENAI_BASE_URL": "https://litellm.graystone-fb459c5d.southeastasia.azurecontainerapps.io/v1",
|
||||
"LITELLM_MODEL": "taiji/gpt-4o-mini",
|
||||
"WORK_DIR": "/workspace"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -930,7 +954,7 @@ IP.1 = 127.0.0.1
|
||||
container_ports = None
|
||||
if template in ["search_agent", "search_agent_a2a", "search_agent_mcp"]:
|
||||
container_ports = [client.V1ContainerPort(container_port=8080)]
|
||||
elif template in ["jina_search_agent", "azure_blob_agent", "azure_blob_agent_mcp", "azure_blob_agent_a2a", "a2a_litellm_agent", "code_ai_agent", "facebook_agent", "echo_agent", "mysql_agent", "postgresql_agent"]:
|
||||
elif template in ["jina_search_agent", "azure_blob_agent", "azure_blob_agent_mcp", "azure_blob_agent_a2a", "a2a_litellm_agent", "coding_a2a_agent", "code_ai_agent", "facebook_agent", "echo_agent", "mysql_agent", "postgresql_agent"]:
|
||||
container_ports = [client.V1ContainerPort(container_port=8000)]
|
||||
|
||||
# 创建Pod规格
|
||||
@@ -2475,6 +2499,7 @@ echo "Identity volume initialized successfully"
|
||||
# TODO: Load from template database
|
||||
image_map = {
|
||||
"a2a_litellm_agent": "agnettaiji.azurecr.io/ai-agents/a2a-litellm-agent:heicode-v2-runtime-20260531214518-arm64",
|
||||
"coding_a2a_agent": "agnettaiji.azurecr.io/ai-agents/coding-a2a-agent:latest",
|
||||
"code_manager_agent": "agnettaiji.azurecr.io/ai-agents/code-manager-agent:latest"
|
||||
}
|
||||
image = image_map.get(template, image_map["a2a_litellm_agent"])
|
||||
|
||||
@@ -134,6 +134,29 @@ DEFAULT_TEMPLATES = {
|
||||
"agent_framework": "a2a",
|
||||
"env_requirements": {},
|
||||
},
|
||||
"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": "启动时读取的角色说明文件路径,内容会并入系统提示词",
|
||||
"SERVICE_PORT": "服务端口,默认 8000",
|
||||
},
|
||||
},
|
||||
},
|
||||
"code_ai_agent": {
|
||||
"display_name": "Code AI Agent",
|
||||
"description": "代码 AI 助手 Agent",
|
||||
|
||||
Reference in New Issue
Block a user