Token IS transmitted by HM (confirmed); the agent didn't enforce it because AM hadn't deployed the image containing the §5 check to production. So the debug log is unnecessary — removed. Contract §0.1 updated: token-check is "code-ready, pending AM prod image", not a HM gap. UI access-token/direct-URL display kept. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
8.9 KiB
Go
229 lines
8.9 KiB
Go
package controller
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/common"
|
|
)
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// AM template-agent adapter (the ONLY place that knows AM's template HTTP API).
|
|
//
|
|
// Template-agent model: HM asks AM to start a template agent with the selected
|
|
// resources injected as env; AM returns a unique subdomain + access token; the
|
|
// desktop client then talks to that subdomain directly over SSE.
|
|
//
|
|
// When AM finalises its real API, change ONLY:
|
|
// - the default paths in the *Path() helpers (or set the env overrides), and
|
|
// - the request payload / response field names in the three am* functions.
|
|
// No other HM code needs to change.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// amStartArgs is the input to start a template agent. The template definition
|
|
// (.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
|
|
Env map[string]string
|
|
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
|
|
Subdomain string
|
|
AccessToken string
|
|
Status string
|
|
}
|
|
|
|
func agentTemplateStartPath() string {
|
|
// 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 {
|
|
// Lifecycle lives under the same namespace as create (POST /agents).
|
|
p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_PATH", "/agents/{agent_id}")
|
|
return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID))
|
|
}
|
|
|
|
func agentTemplateAgentStopPath(agentRuntimeID string) string {
|
|
p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_STOP_PATH", "/agents/{agent_id}/stop")
|
|
return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID))
|
|
}
|
|
|
|
// agentTemplateStartTimeout is the timeout for starting a template agent —
|
|
// longer than the default runtime timeout because AM provisions the agent
|
|
// (container + subdomain) synchronously before responding.
|
|
func agentTemplateStartTimeout() time.Duration {
|
|
sec := common.GetEnvOrDefault("AGENT_RUNTIME_START_TIMEOUT_SECONDS", 60)
|
|
if sec <= 0 {
|
|
sec = 60
|
|
}
|
|
return time.Duration(sec) * time.Second
|
|
}
|
|
|
|
// amTemplateDo performs an AM HTTP call and returns the decoded `data` object.
|
|
// Reuses the existing runtime config (base URL / service token). A non-zero
|
|
// timeout overrides the default runtime timeout (used by the start call).
|
|
func amTemplateDo(ctx context.Context, method, path string, body any, timeout time.Duration) (map[string]any, error) {
|
|
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeAgent)
|
|
endpoint, err := agentRuntimeURL(cfg.BaseURL, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if timeout <= 0 {
|
|
timeout = cfg.Timeout
|
|
}
|
|
var reader io.Reader
|
|
if body != nil {
|
|
raw, err := common.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
reader = bytes.NewReader(raw)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, endpoint, reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if cfg.Token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+cfg.Token)
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, fmt.Errorf("AM %s %s returned HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
|
|
}
|
|
var envelope map[string]any
|
|
if len(raw) > 0 {
|
|
if err := common.Unmarshal(raw, &envelope); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if msg := agentRuntimeEnvelopeError(envelope); msg != "" {
|
|
return nil, errors.New(msg)
|
|
}
|
|
return extractAgentRuntimeData(envelope), nil
|
|
}
|
|
|
|
// 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) {
|
|
// 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
|
|
}
|
|
// MODEL_NAME must be a real model on the HM gateway. Template frontmatter
|
|
// carries a Claude-style tier hint (opus/sonnet/haiku) which is NOT a gateway
|
|
// model — fall back to the gateway default (gpt-5.4, env-overridable).
|
|
modelName := common.GetEnvOrDefaultString("AGENT_RUNTIME_DEFAULT_MODEL", "gpt-5.4")
|
|
switch strings.ToLower(strings.TrimSpace(args.Model)) {
|
|
case "", "opus", "sonnet", "haiku", "claude":
|
|
// keep the gateway default
|
|
default:
|
|
modelName = strings.TrimSpace(args.Model)
|
|
}
|
|
env["MODEL_NAME"] = modelName
|
|
env["OPENAI_BASE_URL"] = publicV1BaseURL()
|
|
// NOTE: OPENAI_API_KEY and AGENT_ACCESS_TOKEN are already in args.Env (the
|
|
// handler mints + injects them before calling us) and are forwarded as-is by
|
|
// the copy loop above. We don't add model/auth keys here.
|
|
|
|
// 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{
|
|
"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. AM's access_token (if any) is ignored — HM mints its own
|
|
// per-agent access token (AGENT_ACCESS_TOKEN) and returns that to the client;
|
|
// AM authorizes callers by comparing the X-Agent-Access-Token header to its
|
|
// env AGENT_ACCESS_TOKEN locally.
|
|
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: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name),
|
|
Subdomain: subdomain,
|
|
AccessToken: stringFromMap(data, "access_token", "token"),
|
|
Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"),
|
|
}, nil
|
|
}
|
|
|
|
// amGetAgentStatus pulls the live status of a running template agent from AM,
|
|
// so HM can tell whether the agent is still alive / has crashed.
|
|
// Proposed contract: GET /api/agent/agents/{agent_id} -> data.status.
|
|
func amGetAgentStatus(ctx context.Context, runtimeID string) (string, error) {
|
|
data, err := amTemplateDo(ctx, http.MethodGet, agentTemplateAgentPath(runtimeID), nil, 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return stringFromMap(data, "status", "runtime_status", "state"), nil
|
|
}
|
|
|
|
func amStopTemplateAgent(ctx context.Context, runtimeID string) error {
|
|
_, err := amTemplateDo(ctx, http.MethodPost, agentTemplateAgentStopPath(runtimeID), nil, 0)
|
|
return err
|
|
}
|
|
|
|
func amDeleteTemplateAgent(ctx context.Context, runtimeID string) error {
|
|
_, err := amTemplateDo(ctx, http.MethodDelete, agentTemplateAgentPath(runtimeID), nil, 0)
|
|
return err
|
|
}
|