From 074a3cc7e7cd3c18188f797090197836381716e8 Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 4 Jun 2026 15:30:33 +0800 Subject: [PATCH] feat(agent): align HM to AM's coding_a2a_agent contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt HM's template-agent integration to AM's actual CODING_A2A API (per their doc), keeping it isolated in agent_template_runtime.go: - start payload -> AM's POST /agents { name, template:"coding_a2a_agent", framework:"A2A", config:{user_id,...}, env } with the template .md folded into env.AGENT_INSTRUCTION_TEXT, template_key -> AGENT_ROLE_NAME, model gateway via OPENAI_BASE_URL + MODEL_NAME (OPENAI_API_KEY left to the client per A2A request). - response parse -> access_info.domain/external_ip -> subdomain, namespace/name -> runtime_id; AM issues no access_token (client uses A2A api_key). - env names aligned to AM: GIT_DEFAULT_BRANCH, POSTGRES_* (was PG_*), AZURE_BLOB_ACCOUNT_NAME/CONTAINER/ACCOUNT_KEY (was BLOB_*); source keys aligned to the resource-binding form (db_name/username/database_password/access_key). Only AM-supported types (git/mysql/postgres/azure-blob); vm/redis/mongo/bucket now rejected as unsupported until AM adds them. - frontend: resources page splits DB into MySQL/PostgreSQL (correct provider), drops vm; deploy page hides unsupported resource types. - docs: AM contract + client doc updated to the real env names, payload, and the A2A direct-connect (message/send · message/stream) + api_key auth. - tests updated for the new env names + AM payload/response shape. All green. Co-Authored-By: Claude Opus 4.8 --- docs/integration/heicode-am-contract.md | 68 +++++++-------- .../integration/heicode-desktop-client-api.md | 30 +++++-- heicode/controller/agent_template_env.go | 79 ++++++----------- heicode/controller/agent_template_handlers.go | 1 + heicode/controller/agent_template_runtime.go | 86 +++++++++++++------ heicode/controller/agent_template_test.go | 68 +++++++++------ heicode/web/default/dist/index.html | 2 +- .../deploy-agent/deploy-agent-page.tsx | 7 +- .../src/features/resources/resources-page.tsx | 37 ++++---- 9 files changed, 213 insertions(+), 165 deletions(-) diff --git a/docs/integration/heicode-am-contract.md b/docs/integration/heicode-am-contract.md index 71514f9..497e236 100644 --- a/docs/integration/heicode-am-contract.md +++ b/docs/integration/heicode-am-contract.md @@ -16,41 +16,40 @@ HM 用 `Authorization: Bearer ` 调 AM。统一响应信封建议 `{ "success": true, "data": {...} }`(HM 会从 `data` 取值;失败给 `{ "success": false, "message": "..." }`)。 -### 1.1 启动 agent(核心) -`POST {AM}/api/agent/agents/start` +### 1.1 启动 agent(核心,已对齐你们的 `POST /agents`) +`POST {AM}/agents` -**请求体(HM 发):** +**请求体(HM 实际发送的,已按你们 CODING_A2A §2 对齐):** ```json { - "manager_deployment_id": "dep_b5fab27e9255", // HM 侧记录 id,回调/排查用 - "template_key": "architect", // 模板标识 - "agent_definition": "---\nname: architect\nmodel: opus\n...\n---\n...", - "model": "opus", // 从模板 frontmatter 解析(可空) - "env": { // 资源配置,注入 agent 的 .env(见 §2) - "GIT_REPO_URL": "https://github.com/owner/repo", - "GIT_TOKEN": "ghp_…", - "VM_HOST": "1.2.3.4", "VM_USER": "deploy", "VM_PASSWORD": "…" + "name": "dep-b5fab27e9255", // 由 HM 部署 id 派生(DNS 安全) + "template": "coding_a2a_agent", + "framework": "A2A", + "config": { + "user_id": "22", + "manager_deployment_id": "dep_b5fab27e9255", + "callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events" }, - "callback_url": "https://code.xinghanlab.com/api/agent/callbacks/runtime-events" + "env": { + "AGENT_ROLE_NAME": "architect", + "AGENT_INSTRUCTION_TEXT": "---\nname: architect\n---\n...", + "OPENAI_BASE_URL": "https://code.xinghanlab.com/v1", + "MODEL_NAME": "gpt-5.4", + "GIT_PROVIDER": "github", "GIT_REPO_URL": "...", "GIT_TOKEN": "…", + "MYSQL_HOST": "...", "MYSQL_PASSWORD": "…" + } } ``` -**响应 data(AM 返回):** -```json -{ "runtime_id": "rt_xxx", "subdomain": "https://abc123.agents.example.com", "access_token": "…", "status": "running" } -``` -- `runtime_id`:AM 侧实例 id(HM 存,用于后续 status/stop/delete)。HM 也接受 `agent_id`/`id`/`deployment_id` 作为别名。 -- `subdomain`:agent 对外**唯一可直连地址**(完整 URL 最佳)。HM 也接受 `address`/`url` 别名。 -- `access_token`:客户端连 agent 时出示的令牌(见 §3)。HM 也接受 `token` 别名。 -- `status`:`running`/`starting`/… HM 也接受 `runtime_status` 别名。 +**响应(HM 解析你们返回的字段):** +- `runtime_id` ← `runtime_id`/`agent_id`/`id`/`name`/`namespace`(取到的实例标识,用于 status/stop/delete)。 +- `subdomain` ← `subdomain` 或 `access_info.domain` / `access_info.external_ip`(agent 直连地址)。 +- `access_token` ← `access_token`/`token`(你们当前不返回 → HM 留空,客户端用 A2A `api_key`,见 §3)。 +- `status` ← `status`/`runtime_status`(取不到默认 `running`)。 -**AM 启动时必须做的:** -1. 用 `agent_definition`(.md:frontmatter + 系统提示)作为该 agent 的**角色/行为定义**(等价 Claude Code subagent)。 -2. 把 `env` 注入 agent 的 `.env`/进程环境,**agent 只读 env 即知道有哪些资源**。 -3. 分配唯一子域名 + 生成 access_token,**agent 端自行校验该 token**(§3)。 -4. 该 agent 用模型时**调 HM 的 `/v1/*`**(带用户身份,HM 计费)——不要直连上游模型。 +**AM 启动时做的(你们已实现):**用 `env.AGENT_INSTRUCTION_TEXT`+`AGENT_ROLE_NAME` 设角色;注入 env;agent 用模型走 `OPENAI_BASE_URL`(=HM `/v1`)。 -> ⚠️ **provisioning 是同步的**:HM 启动调用超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`),请在该时间内完成起容器+分配地址并返回。 +> ⚠️ provisioning 同步:HM 超时默认 60s(`AGENT_RUNTIME_START_TIMEOUT_SECONDS`)。 ### 1.2 取 agent 状态 `GET {AM}/api/agent/agents/{runtime_id}` @@ -68,19 +67,20 @@ HM 用 `Authorization: Bearer ` 调 AM。统一响应信封建议 HM 把用户绑定的资源按**资源类型 + provider**解成固定 env 名(代码 `controller/agent_template_env.go`)。**AM 的 agent 模板按这些名字读 env**;用户在 HM 只填业务字段、看不到 env 名。 +> **2026-06-04 已对齐 AM 的 `coding_a2a_agent`**:env 名按你们文档实现。当前**只支持 AM 已支持的 4 类**(git / mysql / postgres / azure blob);vm / redis / mongo / 对象桶等 HM 暂不下发,待 AM 支持再加。 + | 资源类型 | provider | 注入的 env(非密 + 密钥) | |---|---|---| -| git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_BRANCH` `GIT_API_BASE` · `GIT_TOKEN`(密) | -| vm | (ssh) | `VM_HOST` `VM_PORT` `VM_USER` · `VM_PASSWORD` / `VM_SSH_KEY`(密) | +| git | github / gitea / gitlab | `GIT_PROVIDER` `GIT_REPO_URL` `GIT_DEFAULT_BRANCH` · `GIT_TOKEN`(密) | | database | mysql | `MYSQL_HOST` `MYSQL_PORT` `MYSQL_DATABASE` `MYSQL_USER` · `MYSQL_PASSWORD`(密) | -| database | pg(postgres) | `PG_HOST` `PG_PORT` `PG_DATABASE` `PG_USER` · `PG_PASSWORD`(密) | -| database | redis | `REDIS_HOST` `REDIS_PORT` `REDIS_DB` · `REDIS_PASSWORD`(密) | -| database | mongo | `MONGO_HOST` `MONGO_PORT` `MONGO_DATABASE` `MONGO_USER` · `MONGO_PASSWORD`(密) | -| storage(blob) | azure blob | `BLOB_ACCOUNT` `BLOB_CONTAINER` · `BLOB_KEY`(密) | -| storage(bucket) | s3/oss/minio | `BUCKET_ENDPOINT` `BUCKET_REGION` `BUCKET_NAME` · `BUCKET_ACCESS_KEY_ID` `BUCKET_SECRET_ACCESS_KEY`(密) | +| database | postgres | `POSTGRES_HOST` `POSTGRES_PORT` `POSTGRES_DATABASE` `POSTGRES_USER` · `POSTGRES_PASSWORD`(密) | +| storage | azure blob | `AZURE_BLOB_ACCOUNT_NAME` `AZURE_BLOB_CONTAINER` · `AZURE_BLOB_ACCOUNT_KEY`(密) | + +另外随启动注入(模型网关):`OPENAI_BASE_URL`(= HM `/v1`)、`MODEL_NAME`。**`OPENAI_API_KEY` 不在启动时注入** —— 由客户端在 A2A 请求里带 `api_key`(你们文档 §7);后续若走 V2 解密路径再调整。 - 缺失的可选字段不会出现在 env 里(agent 自行容错)。 - 同类型资源 HM 限制只挂一个(避免 env 名冲突)。 +- 角色/指令注入:HM 把模板 .md 放进 `env.AGENT_INSTRUCTION_TEXT`、模板 key 放进 `env.AGENT_ROLE_NAME`(对齐你们 §4)。 --- @@ -114,7 +114,7 @@ HM 这些路径都可用环境变量覆盖(默认值见 §1),AM 若用别的路 |---|---|---| | `AGENT_RUNTIME_BASE_URL` | — | AM 基址(必须配,HTTPS/私网) | | `AGENT_RUNTIME_SERVICE_TOKEN` | — | HM 调 AM 的 Bearer | -| `AGENT_RUNTIME_AGENT_START_PATH` | `/api/agent/agents/start` | 启动 | +| `AGENT_RUNTIME_AGENT_START_PATH` | `/agents` | 启动(你们的 create) | | `AGENT_RUNTIME_AGENT_PATH` | `/api/agent/agents/{agent_id}` | 取状态(GET)/删除(DELETE) | | `AGENT_RUNTIME_AGENT_STOP_PATH` | `/api/agent/agents/{agent_id}/stop` | 停止 | | `AGENT_RUNTIME_START_TIMEOUT_SECONDS` | `60` | 启动超时 | diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index 27d98f4..91cd279 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -170,17 +170,31 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) ) --- -## 6. 直连 Agent(客户端 ↔ agent,SSE)🔴 +## 6. 直连 Agent(客户端 ↔ agent,A2A 协议) -> 这一段是 **客户端直接连 agent**,不经过 HM。HM 只负责把 `subdomain + access_token` 给你。 +> 客户端**直接连 agent 子域名**(§5 的 `subdomain`),不经过 HM。agent 是 AM 的 `coding_a2a_agent`,走 **A2A 协议**。 -- 从 §5 列表拿到某个 `status=running` agent 的 `subdomain` 和 `access_token`。 -- 客户端**直连该子域名,SSE 双向通信**,请求携带 `access_token`(具体头部/握手以 AM 的 agent 端契约为准)。 -- **agent 自己校验 token**;token 错/缺则拒绝(防公网裸奔)。 -- agent 用 `.env` 里注入的资源(git/vm/db/blob 配置)自行干活;**用模型仍打 HM `/v1/*`**。 -- agent 不会把 `.env`/密钥回显到对话或日志(由 AM 保证)。 +- **同步**:`POST {subdomain}/message/send` +- **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`) +- **发现/健康**:`GET {subdomain}/.well-known/agent.json`、`GET {subdomain}/health` -> 该直连契约(SSE 握手、token 头名、消息格式)由 AM 的 agent 端定义,定稿后补入本节。 +请求体(JSON-RPC,A2A): +```json +{ "jsonrpc":"2.0", "id":"task-1", "method":"message/send", + "params": { + "api_key": "sk-…", // ★ 模型 key,agent 用它调 HM /v1 计费(客户端自带) + "model": "gpt-5.4", + "message": { "role":"user", "parts":[ {"kind":"text","text":"…"} ] }, + "configuration": { "workspace": { "root_dir":"/workspace", "allowed_paths":["a.py"] } } + } +} +``` + +- **鉴权 = `params.api_key`**(模型 sk-,客户端自带的网关 key);agent 凭它调 HM `/v1/*` 计费。HM 当前列表里的 `access_token` 字段对该 agent**为空**(AM 不发专属 token);连 agent 用上面的 `api_key`。 +- agent 用 `.env` 里注入的资源(git/mysql/postgres/azure-blob)自行干活;工具:`read_file/write_file/edit_file/run_command/git_*/run_database_query/list_blob_objects` 等。缺配置的资源工具调用会返回 `resource not configured`,不阻塞。 +- 可在 `params.configuration.resources` 里按需覆盖资源(请求级覆盖启动级)。 + +> ⚠️ 安全:当前 agent 端鉴权仅为"持有效 api_key",非 per-agent 专属令牌。HM 侧规划**用 V2 解密接口**做按用户隔离(客户端加密请求→agent 解密拿 key),或由 AM 自行加门。上线公网前需确认该端点不被他人用任意 key 驱动(见 AM 契约 §3/§4)。 --- diff --git a/heicode/controller/agent_template_env.go b/heicode/controller/agent_template_env.go index 61a6730..071c662 100644 --- a/heicode/controller/agent_template_env.go +++ b/heicode/controller/agent_template_env.go @@ -31,101 +31,73 @@ const ( func normalizeProvider(p string) string { switch strings.ToLower(strings.TrimSpace(p)) { case "postgres", "postgresql", "pg": - return "pg" + return "postgres" case "mysql", "mariadb": return "mysql" - case "redis": - return "redis" - case "mongo", "mongodb", "mg": - return "mongo" case "github": return "github" case "gitea": return "gitea" case "gitlab": return "gitlab" - case "blob", "azureblob", "azure-blob": + case "blob", "azureblob", "azure-blob", "azure": return "blob" - case "bucket", "s3", "oss", "minio", "cos": - return "bucket" default: return strings.ToLower(strings.TrimSpace(p)) } } +// dbEnvSpec maps the resource-binding form fields (host/port/db_name/username + +// secret database_password) to the AM-expected {PREFIX}_* env names. func dbEnvSpec(prefix string) []envField { return []envField{ {prefix + "_HOST", envMeta, "host"}, {prefix + "_PORT", envMeta, "port"}, - {prefix + "_DATABASE", envMeta, "database"}, - {prefix + "_USER", envMeta, "user"}, - {prefix + "_PASSWORD", envSecret, "password"}, + {prefix + "_DATABASE", envMeta, "db_name"}, + {prefix + "_USER", envMeta, "username"}, + {prefix + "_PASSWORD", envSecret, "database_password"}, } } -// builtinEnvSpec returns the fixed env mapping for a binding, keyed by resource -// type and provider. Built into HM; not user-configurable. +// builtinEnvSpec returns the fixed env mapping for a binding. The env NAMES match +// what AM's coding_a2a_agent reads (see docs/integration/heicode-am-contract.md +// §2); the source KEYS match what the resource-binding form stores. // -// Supported: git (gitea/github/gitlab), vm, database (mysql/pg/redis/mongo), -// storage (azure blob / object-storage bucket). +// Supported by AM today: git (github/gitea/gitlab) · database (mysql/postgres) · +// azure blob. Other types (vm/redis/mongo/object-bucket) return nil → the deploy +// is rejected with an "unsupported" error until AM adds them. func builtinEnvSpec(resourceType, provider string) []envField { rt := strings.ToLower(strings.TrimSpace(resourceType)) p := normalizeProvider(provider) switch rt { case "git": - // Same env names across gitea/github/gitlab; provider passed through. return []envField{ {"GIT_PROVIDER", envMeta, "provider"}, {"GIT_REPO_URL", envMeta, "repo_url"}, - {"GIT_BRANCH", envMeta, "default_branch"}, - {"GIT_API_BASE", envMeta, "api_base"}, + {"GIT_DEFAULT_BRANCH", envMeta, "default_branch"}, {"GIT_TOKEN", envSecret, "token"}, } - case "vm": - return []envField{ - {"VM_HOST", envMeta, "host"}, - {"VM_PORT", envMeta, "port"}, - {"VM_USER", envMeta, "user"}, - {"VM_PASSWORD", envSecret, "password"}, - {"VM_SSH_KEY", envSecret, "private_key"}, - } case "database", "db": switch p { case "mysql": return dbEnvSpec("MYSQL") - case "pg": - return dbEnvSpec("PG") - case "mongo": - return dbEnvSpec("MONGO") - case "redis": - return []envField{ - {"REDIS_HOST", envMeta, "host"}, - {"REDIS_PORT", envMeta, "port"}, - {"REDIS_DB", envMeta, "db"}, - {"REDIS_PASSWORD", envSecret, "password"}, - } + case "postgres": + return dbEnvSpec("POSTGRES") default: - return dbEnvSpec("DB") + return nil // AM supports only mysql / postgres } case "blob", "storage": - if p == "bucket" { + if p == "blob" || p == "azure" || p == "" { return []envField{ - {"BUCKET_ENDPOINT", envMeta, "endpoint"}, - {"BUCKET_REGION", envMeta, "region"}, - {"BUCKET_NAME", envMeta, "bucket"}, - {"BUCKET_ACCESS_KEY_ID", envSecret, "access_key_id"}, - {"BUCKET_SECRET_ACCESS_KEY", envSecret, "secret_access_key"}, + {"AZURE_BLOB_ACCOUNT_NAME", envMeta, "account"}, + {"AZURE_BLOB_CONTAINER", envMeta, "container"}, + {"AZURE_BLOB_ACCOUNT_KEY", envSecret, "access_key"}, } } - // Default: Azure Blob. - return []envField{ - {"BLOB_ACCOUNT", envMeta, "account"}, - {"BLOB_CONTAINER", envMeta, "container"}, - {"BLOB_KEY", envSecret, "key"}, - } + return nil // object-storage buckets not supported by AM yet default: - return nil + return nil // vm / other not supported by AM yet } } @@ -169,6 +141,11 @@ func buildAgentEnvFromBindings(userID int, bindingIDs []int) (map[string]string, if strings.TrimSpace(binding.Metadata) != "" { _ = common.UnmarshalJsonStr(binding.Metadata, &metadata) } + // provider lives on the binding row, not in metadata — surface it so + // GIT_PROVIDER (and similar) resolve. + if _, ok := metadata["provider"]; !ok && strings.TrimSpace(binding.Provider) != "" { + metadata["provider"] = binding.Provider + } // Resolve the secret JSON once per binding, only if one is bound. var secret map[string]any diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go index 9dcf173..a415d74 100644 --- a/heicode/controller/agent_template_handlers.go +++ b/heicode/controller/agent_template_handlers.go @@ -120,6 +120,7 @@ func HeicodeDeployAgent(c *gin.Context) { // Ask AM to start the agent with the template definition (.md) + env injected. result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{ ManagerDeploymentID: deploymentID, + UserID: strconv.Itoa(userID), TemplateKey: tpl.TemplateKey, AgentDefinition: tpl.Definition, Model: tpl.Model, diff --git a/heicode/controller/agent_template_runtime.go b/heicode/controller/agent_template_runtime.go index 3269cb6..15230af 100644 --- a/heicode/controller/agent_template_runtime.go +++ b/heicode/controller/agent_template_runtime.go @@ -31,6 +31,7 @@ import ( // (.md) is maintained by HM and sent to AM here. type amStartArgs struct { ManagerDeploymentID string + UserID string TemplateKey string AgentDefinition string // the agent .md (system prompt + frontmatter) Model string @@ -38,6 +39,13 @@ type amStartArgs struct { CallbackURL string } +// publicV1BaseURL is the model gateway base URL the started agent should call +// for models (OPENAI_BASE_URL). +func publicV1BaseURL() string { + base := strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_PUBLIC_BASE_URL", "https://code.xinghanlab.com")), "/") + return base + "/v1" +} + // amStartResult is what AM returns after starting a template agent. type amStartResult struct { RuntimeID string @@ -47,7 +55,8 @@ type amStartResult struct { } func agentTemplateStartPath() string { - return common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_START_PATH", "/api/agent/agents/start") + // AM's agent-manager create endpoint (CODING_A2A doc §2: POST /agents). + return common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_START_PATH", "/agents") } func agentTemplateAgentPath(agentRuntimeID string) string { @@ -121,39 +130,68 @@ func amTemplateDo(ctx context.Context, method, path string, body any, timeout ti return extractAgentRuntimeData(envelope), nil } -// amStartTemplateAgent asks AM to start an agent from the HM-maintained template -// definition (.md) with the resolved resources injected as env. -// Proposed request: {manager_deployment_id, template_key, agent_definition, -// model, env, callback_url}. -// Proposed response data: {runtime_id, subdomain, access_token, status}. +// amStartTemplateAgent asks AM to start a coding_a2a_agent. It maps HM's model +// (template .md + resource env) onto AM's create contract (CODING_A2A doc): +// POST /agents { name, template:"coding_a2a_agent", framework:"A2A", +// config:{user_id}, env:{...} } where the role/instruction/model are folded into +// env. AM's create response carries access_info.domain / namespace etc. func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult, error) { - // The env may carry plaintext secrets (vm password, db password, blob key…). - // Warn if the AM endpoint is plain http on a non-loopback host so they don't - // silently transit the network in the clear — AM should be HTTPS / private. - if len(args.Env) > 0 { - base := agentRuntimeClientConfigForMode(agentRuntimeModeAgent).BaseURL - if strings.HasPrefix(base, "http://") && - !strings.Contains(base, "localhost") && !strings.Contains(base, "127.0.0.1") { - common.SysLog("WARNING: starting template agent with secret env over a non-HTTPS AM URL; use HTTPS or a private network") - } + // Build AM's env: resource env + role/instruction + model gateway. + env := map[string]string{} + for k, v := range args.Env { + env[k] = v } + if args.TemplateKey != "" { + env["AGENT_ROLE_NAME"] = args.TemplateKey + } + if strings.TrimSpace(args.AgentDefinition) != "" { + env["AGENT_INSTRUCTION_TEXT"] = args.AgentDefinition + } + if args.Model != "" { + env["MODEL_NAME"] = args.Model + } + env["OPENAI_BASE_URL"] = publicV1BaseURL() + // NOTE: OPENAI_API_KEY is intentionally NOT injected here — the desktop + // client passes its own api_key per A2A request (CODING_A2A §7). A future + // V2-decrypt path may hand the agent a scoped key instead. + + // env may carry plaintext secrets (db password, blob key…) — warn on http. + base := agentRuntimeClientConfigForMode(agentRuntimeModeAgent).BaseURL + if strings.HasPrefix(base, "http://") && + !strings.Contains(base, "localhost") && !strings.Contains(base, "127.0.0.1") { + common.SysLog("WARNING: starting template agent with secret env over a non-HTTPS AM URL; use HTTPS or a private network") + } + + name := strings.ToLower(strings.ReplaceAll(args.ManagerDeploymentID, "_", "-")) payload := map[string]any{ - "manager_deployment_id": args.ManagerDeploymentID, - "template_key": args.TemplateKey, - "agent_definition": args.AgentDefinition, - "model": args.Model, - "env": args.Env, - "callback_url": args.CallbackURL, + "name": name, + "template": "coding_a2a_agent", + "framework": "A2A", + "config": map[string]any{ + "user_id": args.UserID, + "manager_deployment_id": args.ManagerDeploymentID, + "callback_url": args.CallbackURL, + }, + "env": env, } data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(), payload, agentTemplateStartTimeout()) if err != nil { return amStartResult{}, err } + // AM exposes the agent via access_info.{domain,external_ip}; the instance id + // is name/namespace. access_token is not issued by AM (auth = per-request + // api_key); left empty until the V2-decrypt path lands. + subdomain := stringFromMap(data, "subdomain", "address", "url") + if subdomain == "" { + if ai, ok := data["access_info"].(map[string]any); ok { + subdomain = stringFromMap(ai, "domain", "external_ip", "url") + } + } return amStartResult{ - RuntimeID: stringFromMap(data, "runtime_id", "agent_id", "id", "deployment_id"), - Subdomain: stringFromMap(data, "subdomain", "address", "url"), + RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name), + Subdomain: subdomain, AccessToken: stringFromMap(data, "access_token", "token"), - Status: stringFromMap(data, "status", "runtime_status"), + Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"), }, nil } diff --git a/heicode/controller/agent_template_test.go b/heicode/controller/agent_template_test.go index 47227ee..0e0553e 100644 --- a/heicode/controller/agent_template_test.go +++ b/heicode/controller/agent_template_test.go @@ -16,21 +16,21 @@ import ( func TestBuildAgentEnvFromBindings_BuiltinBlobMetadataOnly(t *testing.T) { setupResourceControllerTestDB(t) - // No secret_ref -> secret fields (BLOB_KEY) are skipped, not an error. + // No secret_ref -> AZURE_BLOB_ACCOUNT_KEY is skipped, not an error. b := model.ResourceBinding{ UserId: 7, Name: "blob1", ResourceType: "blob", - Provider: "blob", + Provider: "azure", Metadata: `{"account":"myacct","container":"uploads"}`, } require.NoError(t, model.DB.Create(&b).Error) env, err := buildAgentEnvFromBindings(7, []int{b.Id}) require.NoError(t, err) - require.Equal(t, "myacct", env["BLOB_ACCOUNT"]) - require.Equal(t, "uploads", env["BLOB_CONTAINER"]) - _, hasKey := env["BLOB_KEY"] + require.Equal(t, "myacct", env["AZURE_BLOB_ACCOUNT_NAME"]) + require.Equal(t, "uploads", env["AZURE_BLOB_CONTAINER"]) + _, hasKey := env["AZURE_BLOB_ACCOUNT_KEY"] require.False(t, hasKey) // no secret bound } @@ -38,13 +38,12 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) { setupResourceControllerTestDB(t) cases := []struct { provider string - wantHost string // env var that should carry the host + wantHost string // env var that should carry the host (AM names) }{ {"mysql", "MYSQL_HOST"}, - {"pg", "PG_HOST"}, - {"postgresql", "PG_HOST"}, // alias normalises to pg - {"redis", "REDIS_HOST"}, - {"mg", "MONGO_HOST"}, // alias normalises to mongo + {"postgres", "POSTGRES_HOST"}, + {"postgresql", "POSTGRES_HOST"}, // alias normalises to postgres + {"pg", "POSTGRES_HOST"}, // alias normalises to postgres } for _, tc := range cases { b := model.ResourceBinding{ @@ -52,7 +51,8 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) { Name: "db-" + tc.provider, ResourceType: "database", Provider: tc.provider, - Metadata: `{"host":"db.example","port":"5432","database":"app","user":"u"}`, + // form fields: host/port/db_name/username (+ secret database_password) + Metadata: `{"host":"db.example","port":"5432","db_name":"app","username":"u"}`, } require.NoError(t, model.DB.Create(&b).Error) env, err := buildAgentEnvFromBindings(7, []int{b.Id}) @@ -61,6 +61,14 @@ func TestBuildAgentEnvFromBindings_DatabaseProviderPrefix(t *testing.T) { } } +func TestBuildAgentEnvFromBindings_UnsupportedDBProviderRejected(t *testing.T) { + setupResourceControllerTestDB(t) + b := model.ResourceBinding{UserId: 7, Name: "r", ResourceType: "database", Provider: "redis", Status: "active", Metadata: `{"host":"h"}`} + require.NoError(t, model.DB.Create(&b).Error) + _, err := buildAgentEnvFromBindings(7, []int{b.Id}) // AM supports only mysql/postgres + require.Error(t, err) +} + func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) { setupResourceControllerTestDB(t) for _, provider := range []string{"github", "gitea", "gitlab"} { @@ -69,14 +77,14 @@ func TestBuildAgentEnvFromBindings_GitNamesProviderAgnostic(t *testing.T) { Name: "git-" + provider, ResourceType: "git", Provider: provider, - Metadata: `{"provider":"` + provider + `","repo_url":"https://x/owner/repo","default_branch":"main"}`, + Metadata: `{"repo_url":"https://x/owner/repo","default_branch":"main"}`, } require.NoError(t, model.DB.Create(&b).Error) env, err := buildAgentEnvFromBindings(7, []int{b.Id}) require.NoError(t, err) require.Equal(t, "https://x/owner/repo", env["GIT_REPO_URL"]) - require.Equal(t, "main", env["GIT_BRANCH"]) - require.Equal(t, provider, env["GIT_PROVIDER"]) + require.Equal(t, "main", env["GIT_DEFAULT_BRANCH"]) + require.Equal(t, provider, env["GIT_PROVIDER"]) // sourced from binding.Provider } } @@ -126,37 +134,45 @@ func TestBuildAgentEnvFromBindings_Empty(t *testing.T) { } func TestAgentTemplatePathSubstitution(t *testing.T) { - require.Contains(t, agentTemplateStartPath(), "/start") + require.Contains(t, agentTemplateStartPath(), "/agents") require.Contains(t, agentTemplateAgentStopPath("rt-9"), "/agents/rt-9/stop") require.Contains(t, agentTemplateAgentPath("rt-9"), "/agents/rt-9") } func TestAMStartTemplateAgent_RoundTrip(t *testing.T) { + var gotBody string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "/api/agent/agents/start", r.URL.Path) - body, _ := io.ReadAll(r.Body) - require.Contains(t, string(body), "manager_deployment_id") - require.Contains(t, string(body), "agent_definition") + require.Equal(t, "/agents", r.URL.Path) // AM's create endpoint + b, _ := io.ReadAll(r.Body) + gotBody = string(b) w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"data":{"runtime_id":"rt-9","subdomain":"https://x.example","access_token":"tok","status":"running"}}`)) + // AM-style response: access_info.domain, namespace; no access_token. + _, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"ns-1","access_info":{"domain":"https://abc.agents.example"}}}`)) })) defer srv.Close() t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL) res, err := amStartTemplateAgent(context.Background(), amStartArgs{ ManagerDeploymentID: "dep_1", + UserID: "22", TemplateKey: "architect", AgentDefinition: "---\nname: architect\n---\nbody", - Model: "opus", - Env: map[string]string{"K": "V"}, + Model: "gpt-5.4", + Env: map[string]string{"GIT_REPO_URL": "https://x/r"}, CallbackURL: "https://cb", }) require.NoError(t, err) - require.Equal(t, "rt-9", res.RuntimeID) - require.Equal(t, "https://x.example", res.Subdomain) - require.Equal(t, "tok", res.AccessToken) - require.Equal(t, "running", res.Status) + // payload mapped onto AM's contract + require.Contains(t, gotBody, `"template":"coding_a2a_agent"`) + require.Contains(t, gotBody, "AGENT_INSTRUCTION_TEXT") // .md folded into env + require.Contains(t, gotBody, "AGENT_ROLE_NAME") + require.Contains(t, gotBody, "OPENAI_BASE_URL") + require.Contains(t, gotBody, "manager_deployment_id") + // response parsed + require.Equal(t, "ns-1", res.RuntimeID) + require.Equal(t, "https://abc.agents.example", res.Subdomain) + require.Equal(t, "running", res.Status) // defaulted } func TestAMGetAgentStatus_RoundTrip(t *testing.T) { diff --git a/heicode/web/default/dist/index.html b/heicode/web/default/dist/index.html index d36de8f..a39573a 100644 --- a/heicode/web/default/dist/index.html +++ b/heicode/web/default/dist/index.html @@ -20,7 +20,7 @@ - +
diff --git a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx index fc9b2d3..ed1d88c 100644 --- a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx +++ b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx @@ -138,7 +138,12 @@ export function DeploySubAgentPage() { s.includes(id) ? s.filter((x) => x !== id) : [...s, id] ) - const resources = resourcesQ.data ?? [] + // Only resource types the agent runtime currently supports (git / mysql / + // postgres / azure-blob). vm & others are hidden until AM adds them. + const SUPPORTED_TYPES = new Set(['git', 'database', 'blob']) + const resources = (resourcesQ.data ?? []).filter((r) => + SUPPORTED_TYPES.has(r.resource_type) + ) const templates = templatesQ.data ?? [] const canDeploy = Boolean(selectedTemplate) && !deploy.isPending diff --git a/heicode/web/default/src/features/resources/resources-page.tsx b/heicode/web/default/src/features/resources/resources-page.tsx index 0b72f76..e51f72a 100644 --- a/heicode/web/default/src/features/resources/resources-page.tsx +++ b/heicode/web/default/src/features/resources/resources-page.tsx @@ -5,7 +5,6 @@ import { Database, GitBranch, Plus, - Server, ShieldCheck, Trash2, } from 'lucide-react' @@ -85,30 +84,28 @@ const KINDS: Kind[] = [ secret: [{ k: 'token', label: '访问令牌 (PAT)', ph: '…' }], }, { - key: 'vm', - resource_type: 'vm', - provider: 'ssh', - label: '虚拟机 (SSH)', - icon: Server, - externalFrom: 'host', - fields: [ - { k: 'host', label: '主机 / IP', ph: '20.24.50.121' }, - { k: 'port', label: '端口', ph: '22' }, - { k: 'user', label: '用户名', ph: 'root' }, - ], - secret: [ - { k: 'ssh_key', label: 'SSH 私钥', ph: '-----BEGIN OPENSSH PRIVATE KEY-----', textarea: true }, - ], - }, - { - key: 'database', + key: 'mysql', resource_type: 'database', + provider: 'mysql', + label: 'MySQL', + icon: Database, + externalFrom: 'host', + fields: [ + { k: 'host', label: '主机', ph: 'db.example.com' }, + { k: 'port', label: '端口', ph: '3306' }, + { k: 'db_name', label: '数据库名', ph: 'app' }, + { k: 'username', label: '用户名', ph: 'app' }, + ], + secret: [{ k: 'database_password', label: '数据库密码', ph: '…' }], + }, + { + key: 'postgres', + resource_type: 'database', provider: 'postgres', - label: '数据库', + label: 'PostgreSQL', icon: Database, externalFrom: 'host', fields: [ - { k: 'engine', label: '引擎', ph: 'postgres / mysql' }, { k: 'host', label: '主机', ph: 'db.example.com' }, { k: 'port', label: '端口', ph: '5432' }, { k: 'db_name', label: '数据库名', ph: 'app' },