Files
story/internal/elderagent/client.go
T

229 lines
6.7 KiB
Go
Raw Normal View History

// 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
}