fix(tts): unify voice resolution — dashboard settings affect tts tool (#1175)

The dashboard /tts page writes to system_configs[tts.<provider>.voice],
but the LLM-invoked tts tool was only checking args > agent OtherConfig
> builtin_tool_tenant_configs[tts].default_voice_id. Two different
storage locations → the user's chosen voice was ignored, Edge defaulted
to en-US-AriaNeural even when "HoaiMy" (vi-VN-HoaiMyNeural) was set
correctly in the dashboard.

Add system_configs as a 4th-level fallback so the dashboard becomes
the single source of truth.

- TtsTool gains SetSystemConfigStore(s) setter
- resolveVoiceAndModel takes providerName + looks up
  tts.<provider>.voice/model when no higher-precedence source set
- effectiveProvider resolved BEFORE voice/model so the right key is hit
- Wired at gateway boot in cmd/gateway.go (after pgStores ready)
- 4 unit tests covering: fallback, arg precedence, no-store, empty provider

Verified against production trace 019e6036-44bb-703b-85fa-dee34f7ab2c0
where the tts tool was called with provider=edge, no voice arg, and
defaulted to English instead of the configured Vietnamese voice.
This commit is contained in:
Duc Nguyen authored and GitHub committed 2026-07-18 00:22:48 +07:00
1 parent cc9956c1f9
commit e3dad3c60c
4 files changed
+119 -13

No files matched your search

+4
View File
@@ -278,6 +278,10 @@ func runGateway() {
browserMgr.SetCookieProvider(newStoreBrowserCookieProvider(pgStores.BrowserCookies))
}
if ttsTool != nil && pgStores.SystemConfigs != nil {
ttsTool.SetSystemConfigStore(pgStores.SystemConfigs)
}
// Recover from crashes: flip ghost 'summoning' rows to 'summon_failed'.
// Summon goroutines don't survive process restart; stale DB rows would trap the UI.
if pgStores.Agents != nil {
+34 -8
View File
@@ -27,13 +27,20 @@ import (
// Implements Tool + ContextualTool interfaces.
// Per-call channel is read from ctx for thread-safety.
type TtsTool struct {
mu sync.RWMutex
manager *tts.Manager
vaultIntc *VaultInterceptor
mu sync.RWMutex
manager *tts.Manager
vaultIntc *VaultInterceptor
systemConfigs store.SystemConfigStore
}
func (t *TtsTool) SetVaultInterceptor(v *VaultInterceptor) { t.vaultIntc = v }
func (t *TtsTool) SetSystemConfigStore(s store.SystemConfigStore) {
t.mu.Lock()
defer t.mu.Unlock()
t.systemConfigs = s
}
// NewTtsTool creates a TTS tool backed by the given manager.
func NewTtsTool(mgr *tts.Manager) *TtsTool {
return &TtsTool{manager: mgr}
@@ -98,7 +105,7 @@ type agentAudioConfig struct {
// resolveVoiceAndModel computes the effective voice + model IDs for the
// request using the documented precedence order:
//
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > empty.
// args > agent (store.AgentAudioFromCtx OtherConfig) > tenant (BuiltinToolSettings) > system_configs > empty.
//
// Empty return values signal "use provider default" downstream — they are not
// errors. Missing agent snapshot emits slog.Warn so operators can spot
@@ -108,7 +115,7 @@ type agentAudioConfig struct {
// tts_voice_id override (not from tool args or tenant defaults). Callers use
// this flag to emit a warning when the voice is later found incompatible with
// the selected provider.
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, providerName, argVoice, argModel string) (voice, model string, voiceFromAgent bool) {
voice, model = argVoice, argModel
// Pull agent-level config from the dispatcher-injected snapshot.
@@ -153,6 +160,22 @@ func (t *TtsTool) resolveVoiceAndModel(ctx context.Context, argVoice, argModel s
model = tenantCfg.DefaultModel
}
}
t.mu.RLock()
systemConfigs := t.systemConfigs
t.mu.RUnlock()
if (voice == "" || model == "") && systemConfigs != nil && providerName != "" {
if voice == "" {
if v, err := systemConfigs.Get(ctx, "tts."+providerName+".voice"); err == nil && v != "" {
voice = v
}
}
if model == "" {
if m, err := systemConfigs.Get(ctx, "tts."+providerName+".model"); err == nil && m != "" {
model = m
}
}
}
return voice, model, voiceFromAgent
}
@@ -252,9 +275,6 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
argModel, _ := args["model"].(string)
providerName, _ := args["provider"].(string)
// Resolve voice/model via args > agent (ctx snapshot) > tenant > default.
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, argVoice, argModel)
// Read generic agent TTS params once; adapt PER-ATTEMPT below (Finding #1 CRITICAL).
// Storing generic keys here so each fallback provider gets its own adapted copy.
genericAgentParams := t.resolveAgentGenericTTSParams(ctx)
@@ -264,6 +284,12 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
mgr := t.manager
t.mu.RUnlock()
effectiveProvider := providerName
if effectiveProvider == "" {
effectiveProvider = t.resolvePrimary(ctx, mgr)
}
voice, model, voiceFromAgent := t.resolveVoiceAndModel(ctx, effectiveProvider, argVoice, argModel)
// Determine format based on channel (read from ctx — thread-safe)
channel := ToolChannelFromCtx(ctx)
opts := tts.Options{Voice: voice, Model: model}
+5 -5
View File
@@ -35,7 +35,7 @@ func ctxWithAgentAudio(t *testing.T, voiceID, modelID string) context.Context {
func TestResolveVoiceAndModel_ArgsWinOverAgent(t *testing.T) {
tool := NewTtsTool(makeTTSManager("elevenlabs"))
ctx := ctxWithAgentAudio(t, "AGENT_V", "AGENT_M")
v, m, _ := tool.resolveVoiceAndModel(ctx, "ARG_V", "ARG_M")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "ARG_V", "ARG_M")
if v != "ARG_V" {
t.Errorf("voice: got %q, want ARG_V (args must win)", v)
}
@@ -50,7 +50,7 @@ func TestResolveVoiceAndModel_AgentWinsOverTenantWhenArgsEmpty(t *testing.T) {
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "AGENT_V" {
t.Errorf("voice: got %q, want AGENT_V (agent > tenant)", v)
}
@@ -65,7 +65,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
ctx := WithBuiltinToolSettings(context.Background(), BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_voice_id": "TENANT_V", "default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "TENANT_V" {
t.Errorf("voice: got %q, want TENANT_V", v)
}
@@ -76,7 +76,7 @@ func TestResolveVoiceAndModel_TenantFallbackWhenAgentSilent(t *testing.T) {
func TestResolveVoiceAndModel_EmptyAllMeansDefault(t *testing.T) {
tool := NewTtsTool(makeTTSManager("elevenlabs"))
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "")
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "" {
t.Errorf("voice: got %q, want empty (no sources → provider default)", v)
}
@@ -92,7 +92,7 @@ func TestResolveVoiceAndModel_PartialAgentConfig(t *testing.T) {
ctx = WithBuiltinToolSettings(ctx, BuiltinToolSettings{
"tts": rawJSON(t, map[string]string{"default_model": "TENANT_M"}),
})
v, m, _ := tool.resolveVoiceAndModel(ctx, "", "")
v, m, _ := tool.resolveVoiceAndModel(ctx, "edge", "", "")
if v != "AGENT_V" {
t.Errorf("voice: got %q, want AGENT_V", v)
}
@@ -0,0 +1,76 @@
package tools
import (
"context"
"testing"
)
type fakeSystemConfigStore struct {
data map[string]string
}
func (f *fakeSystemConfigStore) Get(_ context.Context, key string) (string, error) {
return f.data[key], nil
}
func (f *fakeSystemConfigStore) Set(_ context.Context, key, value string) error {
if f.data == nil {
f.data = map[string]string{}
}
f.data[key] = value
return nil
}
func (f *fakeSystemConfigStore) Delete(_ context.Context, key string) error {
delete(f.data, key)
return nil
}
func (f *fakeSystemConfigStore) List(_ context.Context) (map[string]string, error) {
return f.data, nil
}
func TestResolveVoiceAndModel_SystemConfigsFallback(t *testing.T) {
tool := NewTtsTool(nil)
sc := &fakeSystemConfigStore{data: map[string]string{
"tts.edge.voice": "vi-VN-HoaiMyNeural",
"tts.edge.model": "edge-tts-1",
}}
tool.SetSystemConfigStore(sc)
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "vi-VN-HoaiMyNeural" {
t.Errorf("voice fallback failed: got %q, want vi-VN-HoaiMyNeural", v)
}
if m != "edge-tts-1" {
t.Errorf("model fallback failed: got %q, want edge-tts-1", m)
}
}
func TestResolveVoiceAndModel_ArgWinsOverSystemConfigs(t *testing.T) {
tool := NewTtsTool(nil)
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
"tts.edge.voice": "vi-VN-HoaiMyNeural",
}})
v, _, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "en-US-AriaNeural", "")
if v != "en-US-AriaNeural" {
t.Errorf("arg voice must win over system_configs: got %q", v)
}
}
func TestResolveVoiceAndModel_NoStoreNoFallback(t *testing.T) {
tool := NewTtsTool(nil)
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "edge", "", "")
if v != "" || m != "" {
t.Errorf("expected empty fallback when no system_configs wired, got voice=%q model=%q", v, m)
}
}
func TestResolveVoiceAndModel_EmptyProviderSkipsFallback(t *testing.T) {
tool := NewTtsTool(nil)
tool.SetSystemConfigStore(&fakeSystemConfigStore{data: map[string]string{
"tts..voice": "should-not-match",
}})
v, m, _ := tool.resolveVoiceAndModel(context.Background(), "", "", "")
if v != "" || m != "" {
t.Errorf("empty provider must skip lookup, got voice=%q model=%q", v, m)
}
}