Add coding A2A agent template and docs

This commit is contained in:
elipitc
2026-06-04 14:34:59 +08:00
parent 08ac5067be
commit 2e5321e16f
20 changed files with 2069 additions and 6 deletions
@@ -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()
+1 -1
View File
@@ -49,6 +49,7 @@ declare -A AGENTS=(
["azure-blob-agent-a2a"]="agents/azure_blob_agent_a2a/azure_blob_agent_a2a.Dockerfile"
["azure-blob-agent-mcp"]="agents/azure_blob_agent_mcp/azure_blob_agent_mcp.Dockerfile"
["a2a-litellm-agent"]="agents/a2a_litellm_agent/a2a_litellm_agent.Dockerfile"
["coding-a2a-agent"]="agents/coding_a2a_agent/coding_a2a_agent.Dockerfile"
["mysql-agent"]="agents/mysql_agent/mysql_agent.Dockerfile"
["postgresql-agent"]="agents/postgresql_agent/postgresql_agent.Dockerfile"
["jina-search-agent"]="agents/jina_search_agent/jina_search_agent.Dockerfile"
@@ -129,4 +130,3 @@ echo "已推送的镜像:"
for name in "${!AGENTS[@]}"; do
echo " - ${ACR_NAME}/ai-agents/${name}:${TAG}"
done
+6 -1
View File
@@ -17,7 +17,12 @@ import os
# Database URL - PostgreSQL (hardcoded)
DATABASE_URL = "postgresql://taiji:By%40123456.@taijipda.postgres.database.azure.com:5432/taijiagnet"
engine = create_engine(DATABASE_URL)
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_recycle=1800,
pool_use_lifo=True,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
+332
View File
@@ -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 仍会启动,只是在调用对应资源工具时返回未配置提示
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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"])
+23
View File
@@ -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",