229 lines
6.7 KiB
Go
229 lines
6.7 KiB
Go
// 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
|
||
|
|
}
|