- speak_tool.go: the speak tool itself — rune-derived timeout budget, structured failure codes, persona/emotion stamped on the synthesis ctx, audio persisted through the store seam, structured result for the model. - voice_deps.go: the wiring seam (VoiceSynth / VoiceStore + VoiceStoreFunc adapter), voiceEnabled, withSpeakSpec (appended after every schema shrink), and captureVoice (success beats failure, last one wins). - options.go / loop.go: VoiceSynth, VoiceStore, SpeakBudget injection points. - request.go: advertise speak on both toolSchemas return paths. - tools.go / tools_invoke.go: carry the batch voice outcome; split invokeOne / emitToolExecution / spanStatusForToolPhase / invokeParallel into their own file (tools.go was at the 500-line ceiling) and let speak run without a dispatcher. - run.go: keep the voice outcome across tool batches (a later batch without speak must not erase it). Known, deliberate: speak bypasses the capacity allow-set and skill-round narrowing (it belongs to no built-in toolset), so it is also advertised in quiet/awake; execution still passes the tool security gate, where speak is not registered and non-owner actors are denied fail-closed.
153 lines
6.1 KiB
Go
153 lines
6.1 KiB
Go
package loop
|
|
|
|
// 本文件是 speak 的接线面(M57):两个注入依赖的接口、接线判定、
|
|
// 以及「本回合的出站语音结论」从工具结局到 Result.Voice 的提取。
|
|
// 它们刻意留在循环里而不是工具包里 —— speak 需要本回合的人格与
|
|
// 预算口径,而这两样只有循环持有。
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
mvoice "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
|
)
|
|
|
|
// VoiceSynth 是 speak 需要的合成能力(*tts.Service 天然满足)。
|
|
// 接口收窄到**一个方法**:循环只需要「给定文本拿回音频与提供者」,
|
|
// 发音人解析、降级链与配额全在实现内部,循环不重复实现它们。
|
|
type VoiceSynth interface {
|
|
// TTSWithProvider 合成文本并返回音频与**实际**提供者标识
|
|
// (降级后是备用的那个),供日志与可观测性使用。
|
|
TTSWithProvider(ctx context.Context, text string) (mvoice.Audio, string, error)
|
|
}
|
|
|
|
// VoiceRef 是音频副本落盘后的交付描述:循环只需要「叫什么、什么类型、
|
|
// 多大」,与存储层的哈希/去重标记/磁盘路径无关。单独定义而不复用
|
|
// documents.Ref 是为了让循环不 import documents 包 —— 该包的测试会
|
|
// import 循环,反向依赖构成测试期的 import cycle。
|
|
type VoiceRef struct {
|
|
// Name 是服务端存储名(客户端凭它取回音频)。
|
|
Name string
|
|
// MIME 是存储层字节嗅探所得,不是合成侧的声明。
|
|
MIME string
|
|
// Size 是该副本的字节数。
|
|
Size int64
|
|
}
|
|
|
|
// VoiceStore 是 speak 需要的落盘能力。只要一个方法:音频副本必须走文档
|
|
// 存储的内容寻址 + 权限口径,而不是循环自己写文件。*documents.Store 经
|
|
// VoiceStoreFunc 适配即可接上(见该类型的用法说明)。
|
|
type VoiceStore interface {
|
|
// SaveAudio 落盘一份音频副本并返回其交付描述。
|
|
SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
|
}
|
|
|
|
// VoiceStoreFunc 把函数适配为 VoiceStore(与 ImageResolveFunc 同一惯例)。
|
|
// 接线方用它把存储层接到本包的接缝上,避免循环反向依赖 documents:
|
|
//
|
|
// loop.VoiceStoreFunc(func(ctx context.Context, name string, data []byte) (loop.VoiceRef, error) {
|
|
// ref, err := docs.SaveAudio(ctx, name, data)
|
|
// return loop.VoiceRef{Name: ref.Name, MIME: ref.MIME, Size: ref.Size}, err
|
|
// })
|
|
type VoiceStoreFunc func(ctx context.Context, origName string, data []byte) (VoiceRef, error)
|
|
|
|
// SaveAudio 实现 VoiceStore。
|
|
func (f VoiceStoreFunc) SaveAudio(ctx context.Context, origName string, data []byte) (VoiceRef, error) {
|
|
return f(ctx, origName, data)
|
|
}
|
|
|
|
// voiceEnabled 报告 speak 是否已接线。两个依赖**都**非 nil 才算 ——
|
|
// 只接了合成却没有存储时,调用必然在落盘一步失败,不如干脆不广告,
|
|
// 让模型面与接线前逐字节一致。
|
|
func (l *Loop) voiceEnabled() bool {
|
|
return l != nil && l.voiceSynth != nil && l.voiceStore != nil
|
|
}
|
|
|
|
// withSpeakSpec 把 speak 追加到模型面的工具声明末尾。它由 toolSchemas
|
|
// 在**所有收缩之后**调用,因此 speak 不受能力允许集(quiet/awake)与
|
|
// 技能轮次的 allowed-tools 裁剪 —— speak 不在任何内置 toolset 里,
|
|
// 走那些过滤就等于永远不广告。代价(群聊/陌生人也能看到 speak)由
|
|
// 执行侧的工具安全门兜住:speak 未注册进能力登记表,非主人环境下
|
|
// Policy.Allow 以「未知工具」拒绝,fail-closed。
|
|
func (l *Loop) withSpeakSpec(schemas []model.ToolSpec) []model.ToolSpec {
|
|
spec := l.speakSpec()
|
|
if spec == nil {
|
|
return schemas
|
|
}
|
|
for i := range schemas {
|
|
if schemas[i].Name == spec.Name {
|
|
return schemas
|
|
}
|
|
}
|
|
out := make([]model.ToolSpec, 0, len(schemas)+1)
|
|
out = append(out, schemas...)
|
|
return append(out, *spec)
|
|
}
|
|
|
|
// captureVoice 从一批调用的结局里提取本回合的出站语音结论。返回 nil
|
|
// 表示「这一批没有要求语音」,Result.Voice 因此保持 nil,客户端按纯
|
|
// 文本呈现 —— 与没有 speak 之前逐字节一致。
|
|
//
|
|
// 多个 speak 调用时取**最后一个成功的**:成功意味着音频真的落了盘,
|
|
// 后续的重试即使失败也不该让已经交付的音频消失。全都失败时取最后
|
|
// 一个失败的原因,使客户端仍能区分「想用语音但没成」与「本来就用
|
|
// 文本」。
|
|
func (l *Loop) captureVoice(calls []agent.ToolCall, outcomes []ToolOutcome) *OutboundVoice {
|
|
var lastOK, lastFail *OutboundVoice
|
|
n := len(calls)
|
|
if len(outcomes) < n {
|
|
n = len(outcomes)
|
|
}
|
|
for i := 0; i < n; i++ {
|
|
if calls[i].Name != speakToolName || outcomes[i].Skipped {
|
|
continue
|
|
}
|
|
if outcomes[i].Err == nil {
|
|
lastOK = synthesizedVoice(outcomes[i].Result)
|
|
continue
|
|
}
|
|
lastFail = degradedVoice(outcomes[i].Err)
|
|
}
|
|
if lastOK != nil {
|
|
return lastOK
|
|
}
|
|
return lastFail
|
|
}
|
|
|
|
// synthesizedVoice 把 speak 成功时的结构化结果翻成 OutboundVoice。
|
|
// 结果被钩子改写成非对象时返回 nil:拿不到存储名就播放不了,此时
|
|
// 按文本呈现是唯一诚实的选择(DurationMs 保持 0 = 未解析)。
|
|
func synthesizedVoice(result any) *OutboundVoice {
|
|
m, ok := result.(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
v := &OutboundVoice{Requested: true}
|
|
v.Name, _ = m["name"].(string)
|
|
v.Mime, _ = m["mime"].(string)
|
|
v.Size, _ = m["size"].(int64)
|
|
v.DurationMs, _ = m["durationMs"].(int64)
|
|
v.CostMs, _ = m["costMs"].(int)
|
|
v.BudgetMs, _ = m["budgetMs"].(int)
|
|
return v
|
|
}
|
|
|
|
// degradedVoice 把 speak 的失败翻成「想用语音但退回了文本」。原因码
|
|
// 优先取结构化错误的 Code(超时/无音频/落盘失败/文本过长),非结构化
|
|
// 失败一律归 tts_failed,绝不把空原因码交给客户端。
|
|
func degradedVoice(err error) *OutboundVoice {
|
|
v := &OutboundVoice{Requested: true, FallbackReason: speakReasonFailed}
|
|
var se *speakError
|
|
if !errors.As(err, &se) {
|
|
return v
|
|
}
|
|
if se.Code != "" {
|
|
v.FallbackReason = se.Code
|
|
}
|
|
v.BudgetMs = se.BudgetMs
|
|
v.CostMs = se.CostMs
|
|
return v
|
|
}
|