// Package elderagent provisions one persistent Volcano bot per elder. package elderagent import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "time" volcbase "github.com/volcengine/volc-sdk-golang/base" ) type Bot struct { ID string `json:"Id"` Name string `json:"Name"` Config map[string]any `json:"Config"` AgentConfig map[string]any `json:"AgentConfig"` Bindings []map[string]any `json:"Bindings"` } type Provider interface { Ensure(context.Context, string, map[string]any, map[string]any) (Bot, error) } func (c *Client) Update(ctx context.Context, id string, config, agent map[string]any) error { var result map[string]any return c.call(ctx, "AibotUpdate", map[string]any{"Id": id, "Config": config, "AgentConfig": agent}, &result) } // Verify managed fields with a readback. Credentials can be masked by the // provider and are deliberately neither compared nor returned in diagnostics. func (c *Client) VerifyConfiguration(ctx context.Context, id string, config, agent map[string]any) error { var result struct { Bots []Bot `json:"Bots"` } if err := c.call(ctx, "AibotQuery", map[string]any{"Id": id}, &result); err != nil { return err } for _, bot := range result.Bots { if bot.ID != id { continue } for _, path := range [][]string{{"S2SConfig", "ProviderParams", "dialog", "system_role"}, {"MemoryConfig", "Enable"}} { want, wok := configurationField(config, path) got, gok := configurationField(bot.Config, path) if !wok || !gok || !sameJSON(want, got) { return fmt.Errorf("PERSONA_VERIFY_FAILED") } } for _, key := range []string{"WelcomeMessage", "ServerMessageURLForRTS", "ServerMessageSignatureForRTS"} { want, wok := agent[key] got, gok := bot.AgentConfig[key] if wok && (!gok || !sameJSON(want, got)) { return fmt.Errorf("PERSONA_VERIFY_FAILED") } } return nil } return fmt.Errorf("PERSONA_VERIFY_FAILED") } func configurationField(m map[string]any, path []string) (any, bool) { for _, key := range path[:len(path)-1] { m, _ = m[key].(map[string]any) if m == nil { return nil, false } } v, ok := m[path[len(path)-1]] return v, ok } func sameJSON(a, b any) bool { ar, _ := json.Marshal(a) br, _ := json.Marshal(b) return bytes.Equal(ar, br) } type Client struct { Endpoint string Credentials volcbase.Credentials HTTP *http.Client Binding map[string]any } func (c *Client) ForProduct(instance, product string) *Client { if instance != "" && product != "" { c.Binding = map[string]any{"InstanceId": instance, "ProductKey": product} } return c } func (c *Client) bind(ctx context.Context, bot Bot) error { if len(c.Binding) == 0 { return nil } for _, b := range bot.Bindings { if b["InstanceId"] == c.Binding["InstanceId"] && b["ProductKey"] == c.Binding["ProductKey"] { return nil } } var result map[string]any return c.call(ctx, "AibotBind", map[string]any{"Id": bot.ID, "Binding": c.Binding}, &result) } func (c *Client) BindConfigured(ctx context.Context, id string) error { if len(c.Binding) == 0 { return nil } var result struct { Bots []Bot `json:"Bots"` } if err := c.call(ctx, "AibotQuery", map[string]any{"Id": id, "WithBindings": true}, &result); err != nil { return err } for _, bot := range result.Bots { if bot.ID == id { return c.bind(ctx, bot) } } return fmt.Errorf("AibotQuery_NOT_FOUND") } func NewClient(ak, sk, token, endpoint, region string) *Client { if endpoint == "" { endpoint = "https://rtc.volcengineapi.com" } if region == "" { region = "cn-north-1" } return &Client{Endpoint: endpoint, Credentials: volcbase.Credentials{AccessKeyID: ak, SecretAccessKey: sk, SessionToken: token, Service: "rtc", Region: region}, HTTP: &http.Client{Timeout: 15 * time.Second}} } func (c *Client) call(ctx context.Context, action string, body any, result any) error { if c.Credentials.AccessKeyID == "" || c.Credentials.SecretAccessKey == "" { return fmt.Errorf("VOLC_CREDENTIALS_REQUIRED") } u, err := url.Parse(c.Endpoint) if err != nil { return err } q := u.Query() q.Set("Action", action) q.Set("Version", "2025-08-01") u.RawQuery = q.Encode() raw, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") c.Credentials.Sign(req) res, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("%s_TRANSPORT_ERROR", action) } defer res.Body.Close() var env struct { ResponseMetadata struct { Error *struct { Code string `json:"Code"` } `json:"Error"` } `json:"ResponseMetadata"` Result json.RawMessage `json:"Result"` } if err = json.NewDecoder(io.LimitReader(res.Body, 4<<20)).Decode(&env); err != nil { return fmt.Errorf("%s_INVALID_RESPONSE", action) } if env.ResponseMetadata.Error != nil { return fmt.Errorf("%s:%s", action, env.ResponseMetadata.Error.Code) } if res.StatusCode < 200 || res.StatusCode >= 300 { return fmt.Errorf("%s_HTTP_%d", action, res.StatusCode) } return json.Unmarshal(env.Result, result) } // A deterministic name recovers an already-created bot after a lost response. // Callers serialize this method for the same elder, including across API workers. func (c *Client) Ensure(ctx context.Context, name string, config, agent map[string]any) (Bot, error) { var queried struct { Bots []Bot `json:"Bots"` NextIterator string `json:"NextIterator"` } iterator := "" for { if err := c.call(ctx, "AibotQuery", map[string]any{"Name": name, "AccessType": "public", "WithBindings": true, "Limit": 200, "Iterator": iterator}, &queried); err != nil { return Bot{}, err } for _, b := range queried.Bots { if b.Name == name { var updated map[string]any if err := c.call(ctx, "AibotUpdate", map[string]any{"Id": b.ID, "Config": config, "AgentConfig": agent}, &updated); err != nil { return Bot{}, err } b.Config = config b.AgentConfig = agent if err := c.bind(ctx, b); err != nil { return Bot{}, err } return b, nil } } if queried.NextIterator == "" { break } if queried.NextIterator == iterator { return Bot{}, fmt.Errorf("AibotQuery_INVALID_PAGINATION") } iterator = queried.NextIterator } var created struct { Id string `json:"Id"` } if err := c.call(ctx, "AibotCreate", map[string]any{"Name": name, "AccessType": "public", "Config": config, "AgentConfig": agent}, &created); err != nil { return Bot{}, err } if created.Id == "" { return Bot{}, fmt.Errorf("AibotCreate_MISSING_ID") } bot := Bot{ID: created.Id, Name: name, Config: config, AgentConfig: agent} if err := c.bind(ctx, bot); err != nil { return Bot{}, err } return bot, nil }