diff --git a/internal/ai/current.go b/internal/ai/current.go index 9f9aa48..27c6655 100644 --- a/internal/ai/current.go +++ b/internal/ai/current.go @@ -17,13 +17,16 @@ import ( // provider settings. It must be built before dispatching any side effects. // Credential values are never included in errors or logs. type CurrentBound struct { - Mode string - ASR CurrentASR - LLM *CurrentLLM - TTS *CurrentTTS - Prompt string - Opening string - HangupKeywords []string + Mode string + ASR CurrentASR + LLM *CurrentLLM + TTS *CurrentTTS + Prompt string + PromptMaxBytes int + AllowedVariables []string + Opening string + HangupKeywords []string + Conversation CurrentConversation } type CurrentASR struct { @@ -46,6 +49,17 @@ type CurrentTTS struct { Timeout time.Duration } +// CurrentConversation preserves the task's explicit dialogue limits. An +// absent positive limit remains zero; an explicit false remains non-nil. +type CurrentConversation struct { + AllowInterrupt *bool + SilenceTimeout time.Duration + MaxDuration time.Duration + MaxTurns int + SentenceMaxChars int + MaxPendingAudioChunks int +} + type currentAgentSettings struct { Mode string `json:"mode"` ASR struct { @@ -81,11 +95,19 @@ type currentAgentSettings struct { } `json:"format"` } `json:"tts"` Prompt *struct { - Text string `json:"text"` + Text string `json:"text"` + AllowedVariables []string `json:"allowed_variables"` + MaxBytes *int `json:"max_bytes"` } `json:"prompt"` Conversation *struct { - Opening string `json:"opening"` - HangupKeywords []string `json:"hangup_keywords"` + Opening string `json:"opening"` + HangupKeywords []string `json:"hangup_keywords"` + AllowInterrupt *bool `json:"allow_interrupt"` + SilenceTimeoutMS *int64 `json:"silence_timeout_ms"` + MaxDurationMS *int64 `json:"max_duration_ms"` + MaxTurns int `json:"max_turns"` + SentenceMaxChars int `json:"sentence_max_chars"` + MaxPendingAudioChunks int `json:"max_pending_audio_chunks"` } `json:"conversation"` } @@ -196,6 +218,31 @@ func BindCurrent(task configread.CurrentTask, providers map[string]configread.Cu Format: doubaospeech.FormatPCMS16LE, SampleRate: ttsSampleRate, SpeechRate: ttsRate, }, Timeout: ttsTimeout} bound.Prompt = settings.Prompt.Text + bound.AllowedVariables = append([]string(nil), settings.Prompt.AllowedVariables...) + if settings.Prompt.MaxBytes != nil { + bound.PromptMaxBytes = *settings.Prompt.MaxBytes + if len(bound.Prompt) > bound.PromptMaxBytes { + return CurrentBound{}, errors.New("prompt exceeds its approved byte limit") + } + } + if settings.Conversation.AllowInterrupt != nil && *settings.Conversation.AllowInterrupt { + return CurrentBound{}, errors.New("conversation interrupt is unsupported by current media controller") + } + silence, err := currentTimeout(settings.Conversation.SilenceTimeoutMS) + if err != nil { + return CurrentBound{}, fmt.Errorf("conversation silence timeout: %w", err) + } + maxDuration, err := currentTimeout(settings.Conversation.MaxDurationMS) + if err != nil { + return CurrentBound{}, fmt.Errorf("conversation duration: %w", err) + } + bound.Conversation = CurrentConversation{ + AllowInterrupt: settings.Conversation.AllowInterrupt, + SilenceTimeout: silence, MaxDuration: maxDuration, + MaxTurns: settings.Conversation.MaxTurns, + SentenceMaxChars: settings.Conversation.SentenceMaxChars, + MaxPendingAudioChunks: settings.Conversation.MaxPendingAudioChunks, + } bound.Opening = settings.Conversation.Opening bound.HangupKeywords = append([]string(nil), settings.Conversation.HangupKeywords...) return bound, nil diff --git a/internal/ai/current_controls_test.go b/internal/ai/current_controls_test.go new file mode 100644 index 0000000..11cc261 --- /dev/null +++ b/internal/ai/current_controls_test.go @@ -0,0 +1,44 @@ +package ai + +import ( + "strings" + "testing" + "time" +) + +func TestBindCurrentPreservesPromptAndConversationControls(t *testing.T) { + task, providers := currentFixture(t, "full_ai") + bound, err := BindCurrent(task, providers) + if err != nil { + t.Fatal(err) + } + if bound.PromptMaxBytes != 32768 || len(bound.AllowedVariables) != 0 { + t.Fatal("approved prompt controls not preserved") + } + c := bound.Conversation + if c.AllowInterrupt == nil || *c.AllowInterrupt || c.SilenceTimeout != 3*time.Second || c.MaxDuration != 2*time.Minute || c.MaxTurns != 20 || c.SentenceMaxChars != 80 || c.MaxPendingAudioChunks != 32 { + t.Fatalf("approved conversation controls lost: %+v", c) + } +} + +func TestBindCurrentRejectsPromptOverConfiguredByteLimit(t *testing.T) { + task, providers := currentFixture(t, "full_ai") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + agent["prompt"].(map[string]any)["max_bytes"] = 3 + }) + _, err := BindCurrent(task, providers) + if err == nil || !strings.Contains(err.Error(), "prompt") { + t.Fatalf("prompt limit was ignored: %v", err) + } +} + +func TestBindCurrentRejectsUnsupportedBargeInBeforeCall(t *testing.T) { + task, providers := currentFixture(t, "full_ai") + task = changeCurrentAgent(t, task, func(agent map[string]any) { + agent["conversation"].(map[string]any)["allow_interrupt"] = true + }) + _, err := BindCurrent(task, providers) + if err == nil || !strings.Contains(err.Error(), "interrupt") { + t.Fatalf("unsupported interruption cannot silently become false: %v", err) + } +}