diff --git a/agent_templates/agents/coding_a2a_agent/README.md b/agent_templates/agents/coding_a2a_agent/README.md new file mode 100644 index 0000000..645f0f4 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/README.md @@ -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` diff --git a/agent_templates/agents/coding_a2a_agent/__init__.py b/agent_templates/agents/coding_a2a_agent/__init__.py new file mode 100644 index 0000000..b153fce --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/__init__.py @@ -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", +] diff --git a/agent_templates/agents/coding_a2a_agent/a2a_server.py b/agent_templates/agents/coding_a2a_agent/a2a_server.py new file mode 100644 index 0000000..855df35 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/a2a_server.py @@ -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 diff --git a/agent_templates/agents/coding_a2a_agent/agent.py b/agent_templates/agents/coding_a2a_agent/agent.py new file mode 100644 index 0000000..9236bf8 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/agent.py @@ -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") diff --git a/agent_templates/agents/coding_a2a_agent/coding_a2a_agent.Dockerfile b/agent_templates/agents/coding_a2a_agent/coding_a2a_agent.Dockerfile new file mode 100644 index 0000000..12a801a --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/coding_a2a_agent.Dockerfile @@ -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"] diff --git a/agent_templates/agents/coding_a2a_agent/common/__init__.py b/agent_templates/agents/coding_a2a_agent/common/__init__.py new file mode 100644 index 0000000..1d18a0e --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/common/__init__.py @@ -0,0 +1,3 @@ +""" +Common helpers for the coding A2A agent. +""" diff --git a/agent_templates/agents/coding_a2a_agent/common/agent_callback_utils.py b/agent_templates/agents/coding_a2a_agent/common/agent_callback_utils.py new file mode 100644 index 0000000..e4a3e7c --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/common/agent_callback_utils.py @@ -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) diff --git a/agent_templates/agents/coding_a2a_agent/config.py b/agent_templates/agents/coding_a2a_agent/config.py new file mode 100644 index 0000000..204d295 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/config.py @@ -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, "")} diff --git a/agent_templates/agents/coding_a2a_agent/main.py b/agent_templates/agents/coding_a2a_agent/main.py new file mode 100644 index 0000000..6bb5f02 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/main.py @@ -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() diff --git a/agent_templates/agents/coding_a2a_agent/requirements.txt b/agent_templates/agents/coding_a2a_agent/requirements.txt new file mode 100644 index 0000000..2bd3654 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/requirements.txt @@ -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 diff --git a/agent_templates/agents/coding_a2a_agent/resources.py b/agent_templates/agents/coding_a2a_agent/resources.py new file mode 100644 index 0000000..1dcaaca --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/resources.py @@ -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 diff --git a/agent_templates/agents/coding_a2a_agent/tests/test_helpers.py b/agent_templates/agents/coding_a2a_agent/tests/test_helpers.py new file mode 100644 index 0000000..328a026 --- /dev/null +++ b/agent_templates/agents/coding_a2a_agent/tests/test_helpers.py @@ -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() diff --git a/agent_templates/scripts/build_all_agents.sh b/agent_templates/scripts/build_all_agents.sh index 7db7975..396f431 100755 --- a/agent_templates/scripts/build_all_agents.sh +++ b/agent_templates/scripts/build_all_agents.sh @@ -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 - diff --git a/database.py b/database.py index fe0d635..08ad352 100644 --- a/database.py +++ b/database.py @@ -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() diff --git a/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md b/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md new file mode 100644 index 0000000..062872b --- /dev/null +++ b/docs/CODING_A2A_AGENT_CREATE_AND_INVOKE.md @@ -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 仍会启动,只是在调用对应资源工具时返回未配置提示 diff --git a/k8s/agent-manager-deployment.yaml b/k8s/agent-manager-deployment.yaml index 99c6a6c..af7d7c5 100644 --- a/k8s/agent-manager-deployment.yaml +++ b/k8s/agent-manager-deployment.yaml @@ -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: diff --git a/k8s/deployment-with-kubeconfig.yaml b/k8s/deployment-with-kubeconfig.yaml index 784890e..3cd4489 100755 --- a/k8s/deployment-with-kubeconfig.yaml +++ b/k8s/deployment-with-kubeconfig.yaml @@ -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 diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index c5dc0a0..6565e09 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -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 diff --git a/k8s_manager.py b/k8s_manager.py index 6b15854..4c3b989 100644 --- a/k8s_manager.py +++ b/k8s_manager.py @@ -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"]) diff --git a/template_manager.py b/template_manager.py index 34d1450..4f72c8a 100644 --- a/template_manager.py +++ b/template_manager.py @@ -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",