fix config

This commit is contained in:
lpf
2026-03-04 11:04:25 +08:00
parent 6c78ef3b1c
commit d639a5113f
7 changed files with 191 additions and 73 deletions

View File

@@ -61,6 +61,8 @@ type AgentLoop struct {
sessionRunLocks map[string]*sync.Mutex
providerNames []string
providerPool map[string]providers.LLMProvider
providerResponses map[string]config.ProviderResponsesConfig
telegramStreaming bool
ekg *ekg.Engine
providerMu sync.RWMutex
sessionProvider map[string]string
@@ -242,6 +244,8 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
sessionRunLocks: map[string]*sync.Mutex{},
ekg: ekg.New(workspace),
sessionProvider: map[string]string{},
providerResponses: map[string]config.ProviderResponsesConfig{},
telegramStreaming: cfg.Channels.Telegram.Streaming,
}
// Initialize provider fallback chain (primary + proxy_fallbacks).
@@ -253,6 +257,11 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
}
loop.providerPool[primaryName] = provider
loop.providerNames = append(loop.providerNames, primaryName)
if strings.TrimSpace(primaryName) == "proxy" {
loop.providerResponses[primaryName] = cfg.Providers.Proxy.Responses
} else if pc, ok := cfg.Providers.Proxies[primaryName]; ok {
loop.providerResponses[primaryName] = pc.Responses
}
for _, name := range cfg.Agents.Defaults.ProxyFallbacks {
if name == "" {
continue
@@ -270,6 +279,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
if p2, err := providers.CreateProviderByName(cfg, name); err == nil {
loop.providerPool[name] = p2
loop.providerNames = append(loop.providerNames, name)
if pc, ok := cfg.Providers.Proxies[name]; ok {
loop.providerResponses[name] = pc.Responses
}
}
}
@@ -800,10 +812,10 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
})
messages = injectResponsesMediaParts(messages, msg.Media, msg.MediaItems)
options := buildResponsesOptions(8192, 0.7)
options := al.buildResponsesOptions(msg.SessionKey, 8192, 0.7)
var response *providers.LLMResponse
var err error
if msg.Channel == "telegram" && strings.TrimSpace(os.Getenv("CLAWGO_TELEGRAM_STREAMING")) == "1" {
if msg.Channel == "telegram" && al.telegramStreaming {
if sp, ok := al.provider.(providers.StreamingLLMProvider); ok {
streamText := ""
lastPush := time.Now().Add(-time.Second)
@@ -1158,7 +1170,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
"tools_json": formatToolsForLog(providerToolDefs),
})
options := buildResponsesOptions(8192, 0.7)
options := al.buildResponsesOptions(sessionKey, 8192, 0.7)
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, options)
if err != nil {
@@ -1292,46 +1304,59 @@ func (al *AgentLoop) buildProviderToolDefs(toolDefs []map[string]interface{}) []
return providerToolDefs
}
func buildResponsesOptions(maxTokens int64, temperature float64) map[string]interface{} {
func (al *AgentLoop) buildResponsesOptions(sessionKey string, maxTokens int64, temperature float64) map[string]interface{} {
options := map[string]interface{}{
"max_tokens": maxTokens,
"temperature": temperature,
}
responsesCfg := al.responsesConfigForSession(sessionKey)
responseTools := make([]map[string]interface{}, 0, 2)
if strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_WEB_SEARCH")) == "1" {
if responsesCfg.WebSearchEnabled {
webTool := map[string]interface{}{"type": "web_search"}
if contextSize := strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_WEB_SEARCH_CONTEXT_SIZE")); contextSize != "" {
if contextSize := strings.TrimSpace(responsesCfg.WebSearchContextSize); contextSize != "" {
webTool["search_context_size"] = contextSize
}
responseTools = append(responseTools, webTool)
}
if idsRaw := strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_FILE_SEARCH_VECTOR_STORE_IDS")); idsRaw != "" {
ids := splitCommaList(idsRaw)
if len(ids) > 0 {
fileSearch := map[string]interface{}{
"type": "file_search",
"vector_store_ids": ids,
}
if maxNumRaw := strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_FILE_SEARCH_MAX_NUM_RESULTS")); maxNumRaw != "" {
if n, err := strconv.Atoi(maxNumRaw); err == nil && n > 0 {
fileSearch["max_num_results"] = n
}
}
responseTools = append(responseTools, fileSearch)
if len(responsesCfg.FileSearchVectorStoreIDs) > 0 {
fileSearch := map[string]interface{}{
"type": "file_search",
"vector_store_ids": responsesCfg.FileSearchVectorStoreIDs,
}
if responsesCfg.FileSearchMaxNumResults > 0 {
fileSearch["max_num_results"] = responsesCfg.FileSearchMaxNumResults
}
responseTools = append(responseTools, fileSearch)
}
if len(responseTools) > 0 {
options["responses_tools"] = responseTools
}
if include := splitCommaList(strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_INCLUDE"))); len(include) > 0 {
options["responses_include"] = include
if len(responsesCfg.Include) > 0 {
options["responses_include"] = responsesCfg.Include
}
if strings.TrimSpace(os.Getenv("CLAWGO_RESPONSES_STREAM_INCLUDE_USAGE")) == "1" {
if responsesCfg.StreamIncludeUsage {
options["responses_stream_options"] = map[string]interface{}{"include_usage": true}
}
return options
}
func (al *AgentLoop) responsesConfigForSession(sessionKey string) config.ProviderResponsesConfig {
if al == nil {
return config.ProviderResponsesConfig{}
}
name := strings.TrimSpace(al.getSessionProvider(sessionKey))
if name == "" && len(al.providerNames) > 0 {
name = al.providerNames[0]
}
if name == "" {
return config.ProviderResponsesConfig{}
}
if cfg, ok := al.providerResponses[name]; ok {
return cfg
}
return config.ProviderResponsesConfig{}
}
func injectResponsesMediaParts(messages []providers.Message, media []string, mediaItems []bus.MediaItem) []providers.Message {
if len(messages) == 0 || (len(media) == 0 && len(mediaItems) == 0) {
return messages
@@ -1349,15 +1374,28 @@ func injectResponsesMediaParts(messages []providers.Message, media []string, med
})
}
for _, ref := range media {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
// Fallback-only handling for raw media refs. Prefer structured media_items when present.
if len(mediaItems) == 0 {
for _, ref := range media {
ref = strings.TrimSpace(ref)
if ref == "" {
continue
}
if isResponsesFileID(ref) {
parts = append(parts, providers.MessageContentPart{
Type: "input_image",
FileID: ref,
})
continue
}
if !isRemoteReference(ref) {
continue
}
parts = append(parts, providers.MessageContentPart{
Type: "input_image",
ImageURL: ref,
})
}
parts = append(parts, providers.MessageContentPart{
Type: "input_image",
ImageURL: ref,
})
}
for _, item := range mediaItems {
@@ -1371,9 +1409,9 @@ func injectResponsesMediaParts(messages []providers.Message, media []string, med
switch {
case strings.Contains(typ, "image"):
part := providers.MessageContentPart{Type: "input_image"}
if strings.HasPrefix(src, "file_") {
if isResponsesFileID(src) {
part.FileID = src
} else {
} else if isRemoteReference(src) {
part.ImageURL = src
}
if part.FileID != "" || part.ImageURL != "" {
@@ -1381,9 +1419,9 @@ func injectResponsesMediaParts(messages []providers.Message, media []string, med
}
case strings.Contains(typ, "file"), strings.Contains(typ, "document"), strings.Contains(typ, "audio"), strings.Contains(typ, "video"):
part := providers.MessageContentPart{Type: "input_file"}
if strings.HasPrefix(src, "file_") {
if isResponsesFileID(src) {
part.FileID = src
} else {
} else if isRemoteReference(src) {
part.FileURL = src
}
if part.FileID != "" || part.FileURL != "" {
@@ -1399,18 +1437,15 @@ func injectResponsesMediaParts(messages []providers.Message, media []string, med
return messages
}
func splitCommaList(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
func isResponsesFileID(ref string) bool {
return strings.HasPrefix(strings.TrimSpace(ref), "file_")
}
func isRemoteReference(ref string) bool {
trimmed := strings.ToLower(strings.TrimSpace(ref))
return strings.HasPrefix(trimmed, "http://") ||
strings.HasPrefix(trimmed, "https://") ||
strings.HasPrefix(trimmed, "data:")
}
// GetStartupInfo returns information about loaded tools and skills for logging.

View File

@@ -1,10 +1,10 @@
package agent
import (
"os"
"testing"
"clawgo/pkg/bus"
"clawgo/pkg/config"
"clawgo/pkg/providers"
)
@@ -24,32 +24,36 @@ func TestInjectResponsesMediaParts(t *testing.T) {
t.Fatalf("unexpected messages length: %#v", got)
}
parts := got[1].ContentParts
if len(parts) != 4 {
t.Fatalf("expected 4 content parts, got %#v", parts)
if len(parts) != 3 {
t.Fatalf("expected 3 content parts, got %#v", parts)
}
if parts[0].Type != "input_text" || parts[0].Text != "look" {
t.Fatalf("expected first part to preserve input text, got %#v", parts[0])
}
if parts[1].Type != "input_image" || parts[1].ImageURL == "" {
t.Fatalf("expected media URL as input_image, got %#v", parts[1])
if parts[1].Type != "input_image" || parts[1].FileID != "file_img_1" {
t.Fatalf("expected image media item mapped to file_id, got %#v", parts[1])
}
if parts[2].Type != "input_image" || parts[2].FileID != "file_img_1" {
t.Fatalf("expected image media item mapped to file_id, got %#v", parts[2])
}
if parts[3].Type != "input_file" || parts[3].FileID != "file_doc_1" {
t.Fatalf("expected document media item mapped to input_file file_id, got %#v", parts[3])
if parts[2].Type != "input_file" || parts[2].FileID != "file_doc_1" {
t.Fatalf("expected document media item mapped to input_file file_id, got %#v", parts[2])
}
}
func TestBuildResponsesOptionsFromEnv(t *testing.T) {
t.Setenv("CLAWGO_RESPONSES_WEB_SEARCH", "1")
t.Setenv("CLAWGO_RESPONSES_WEB_SEARCH_CONTEXT_SIZE", "high")
t.Setenv("CLAWGO_RESPONSES_FILE_SEARCH_VECTOR_STORE_IDS", "vs_1,vs_2")
t.Setenv("CLAWGO_RESPONSES_FILE_SEARCH_MAX_NUM_RESULTS", "8")
t.Setenv("CLAWGO_RESPONSES_INCLUDE", "output_text,tool_calls")
t.Setenv("CLAWGO_RESPONSES_STREAM_INCLUDE_USAGE", "1")
func TestBuildResponsesOptionsFromConfig(t *testing.T) {
al := &AgentLoop{
providerNames: []string{"proxy"},
providerResponses: map[string]config.ProviderResponsesConfig{
"proxy": {
WebSearchEnabled: true,
WebSearchContextSize: "high",
FileSearchVectorStoreIDs: []string{"vs_1", "vs_2"},
FileSearchMaxNumResults: 8,
Include: []string{"output_text", "tool_calls"},
StreamIncludeUsage: true,
},
},
}
opts := buildResponsesOptions(1024, 0.2)
opts := al.buildResponsesOptions("session-a", 1024, 0.2)
if opts["max_tokens"] != int64(1024) {
t.Fatalf("max_tokens mismatch: %#v", opts["max_tokens"])
}
@@ -72,7 +76,21 @@ func TestBuildResponsesOptionsFromEnv(t *testing.T) {
if _, ok := opts["responses_stream_options"]; !ok {
t.Fatalf("expected responses_stream_options in options")
}
// keep linter happy for unused os import when build tags differ
_ = os.Getenv("CLAWGO_RESPONSES_WEB_SEARCH")
}
func TestInjectResponsesMediaParts_SkipsLocalPathsForResponsesContentParts(t *testing.T) {
msgs := []providers.Message{
{Role: "user", Content: "check local media"},
}
items := []bus.MediaItem{
{Type: "image", Path: "/tmp/a.png"},
{Type: "document", Path: "/tmp/a.pdf"},
}
got := injectResponsesMediaParts(msgs, nil, items)
if len(got[0].ContentParts) != 1 {
t.Fatalf("expected only input_text for local files, got %#v", got[0].ContentParts)
}
if got[0].ContentParts[0].Type != "input_text" {
t.Fatalf("expected input_text only, got %#v", got[0].ContentParts[0])
}
}

View File

@@ -149,6 +149,7 @@ type WhatsAppConfig struct {
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"CLAWGO_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"CLAWGO_CHANNELS_TELEGRAM_TOKEN"`
Streaming bool `json:"streaming"`
AllowFrom []string `json:"allow_from" env:"CLAWGO_CHANNELS_TELEGRAM_ALLOW_FROM"`
AllowChats []string `json:"allow_chats" env:"CLAWGO_CHANNELS_TELEGRAM_ALLOW_CHATS"`
EnableGroups bool `json:"enable_groups" env:"CLAWGO_CHANNELS_TELEGRAM_ENABLE_GROUPS"`
@@ -243,12 +244,22 @@ func (p *ProvidersConfig) UnmarshalJSON(data []byte) error {
}
type ProviderConfig struct {
APIKey string `json:"api_key" env:"CLAWGO_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"CLAWGO_PROVIDERS_{{.Name}}_API_BASE"`
Models []string `json:"models" env:"CLAWGO_PROVIDERS_{{.Name}}_MODELS"`
SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"`
Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"`
TimeoutSec int `json:"timeout_sec" env:"CLAWGO_PROVIDERS_PROXY_TIMEOUT_SEC"`
APIKey string `json:"api_key" env:"CLAWGO_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"CLAWGO_PROVIDERS_{{.Name}}_API_BASE"`
Models []string `json:"models" env:"CLAWGO_PROVIDERS_{{.Name}}_MODELS"`
SupportsResponsesCompact bool `json:"supports_responses_compact" env:"CLAWGO_PROVIDERS_{{.Name}}_SUPPORTS_RESPONSES_COMPACT"`
Auth string `json:"auth" env:"CLAWGO_PROVIDERS_{{.Name}}_AUTH"`
TimeoutSec int `json:"timeout_sec" env:"CLAWGO_PROVIDERS_PROXY_TIMEOUT_SEC"`
Responses ProviderResponsesConfig `json:"responses"`
}
type ProviderResponsesConfig struct {
WebSearchEnabled bool `json:"web_search_enabled"`
WebSearchContextSize string `json:"web_search_context_size"`
FileSearchVectorStoreIDs []string `json:"file_search_vector_store_ids"`
FileSearchMaxNumResults int `json:"file_search_max_num_results"`
Include []string `json:"include"`
StreamIncludeUsage bool `json:"stream_include_usage"`
}
type GatewayConfig struct {
@@ -444,6 +455,7 @@ func DefaultConfig() *Config {
Telegram: TelegramConfig{
Enabled: false,
Token: "",
Streaming: false,
AllowFrom: []string{},
AllowChats: []string{},
EnableGroups: true,

View File

@@ -320,6 +320,18 @@ func validateProviderConfig(path string, p ProviderConfig) []error {
if len(p.Models) == 0 {
errs = append(errs, fmt.Errorf("%s.models must contain at least one model", path))
}
if p.Responses.WebSearchContextSize != "" {
switch p.Responses.WebSearchContextSize {
case "low", "medium", "high":
default:
errs = append(errs, fmt.Errorf("%s.responses.web_search_context_size must be one of: low, medium, high", path))
}
}
if p.Responses.FileSearchMaxNumResults < 0 {
errs = append(errs, fmt.Errorf("%s.responses.file_search_max_num_results must be >= 0", path))
}
errs = append(errs, validateNonEmptyStringList(path+".responses.file_search_vector_store_ids", p.Responses.FileSearchVectorStoreIDs)...)
errs = append(errs, validateNonEmptyStringList(path+".responses.include", p.Responses.Include)...)
return errs
}