feat(loop): wire speak tool into the loop (M57)
- 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.
This commit is contained in:
1 parent
f84a358e30
commit
1347a77d33
8 files changed
+628
-94
No files matched your search
@@ -0,0 +1,336 @@
|
||||
package loop
|
||||
|
||||
// 本文件实现模型侧的 speak 工具(M57)。主人 2026-10-27 的口径是「他可以自己
|
||||
// 选择某次消息是拿文字发 还是去请求 TTS 接口发音频」——**决策是她的**,因此这里
|
||||
// 只提供一个可调用的工具,绝不替她决定什么时候用语音(那会变成幕后置规则)。
|
||||
// 调了 speak = 这条想用语音;不调 = 发文本。
|
||||
//
|
||||
// 三条执行口径:
|
||||
// - 超时预算按**文本 rune 数**推导(speakBudgetMs):短句拿最低预算,长文拿
|
||||
// 上限,中间线性 —— 越长合成越慢,但绝不无限等;
|
||||
// - 成功 = 音频副本**已落盘**(经 VoiceStore 接缝,接线方走 documents.SaveAudio
|
||||
// 的内容寻址 0600 口径)+ 结构化结果(name/mime/size/durationMs/budgetMs/costMs);
|
||||
// - 超时/失败 = 结构化错误(code + 预算 + 实际耗时),**回合绝不因此失败**,
|
||||
// 她能在后续输出里看到「这次语音没成」,客户端也能据此特殊显示。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"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"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
||||
voicetts "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/voice/tts"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
const (
|
||||
// speakToolName 是模型侧工具名。它同时是 Result.Voice 回填时的匹配键。
|
||||
speakToolName = "speak"
|
||||
|
||||
// speakBudgetBaseMs 是任何一次合成的最低预算:即使一个字的请求也要给
|
||||
// 上游留出建连 + 首包的时间,低于它的预算只会把"网络慢"误判成"内容长"。
|
||||
speakBudgetBaseMs = 3000
|
||||
// speakBudgetPerRuneMs 是每个 rune 追加的预算(毫秒)。25ms/rune 约等于
|
||||
// 每秒 40 个字符的合成速度,比实测慢一截,留出余量。
|
||||
speakBudgetPerRuneMs = 25
|
||||
// speakBudgetMaxMs 是预算上限:再长的文本也等这么久,超了就退回文本,
|
||||
// 绝不让她的一条消息把整个回合挂住。
|
||||
speakBudgetMaxMs = 20000
|
||||
// speakMaxRunes 是文本长度硬上限。按上面的公式,约 680 rune 就顶到预算
|
||||
// 上限,再长的文本必然超时 —— 早拒绝能省一次上游调用与一次落盘。
|
||||
speakMaxRunes = 1200
|
||||
|
||||
// speakAudioOrigName 是落盘时给 documents.SaveAudio 的原始名,扩展名
|
||||
// 由上游实际格式拼接(白名单见 voiceref.MediaExtAllowed)。
|
||||
speakAudioOrigName = "speak."
|
||||
|
||||
// 降级/失败原因码(Result.Voice.FallbackReason 与错误正文共用同一套取值,
|
||||
// 客户端据此决定怎么显示「这条本来想用语音」)。
|
||||
speakReasonTimeout = "tts_timeout"
|
||||
speakReasonFailed = "tts_failed"
|
||||
speakReasonNoAudio = "tts_no_audio"
|
||||
speakReasonStoreFailed = "tts_store_failed"
|
||||
speakReasonTooLong = "text_too_long"
|
||||
speakReasonInvalidArgs = "invalid_args"
|
||||
)
|
||||
|
||||
// speakTool 实现 sdk.Tool。依赖全部由循环注入(不自己 new),因此叶子测试
|
||||
// 可以塞桩:合成桩决定成功/超时,落盘桩决定存储名,预算桩决定超时长短。
|
||||
type speakTool struct {
|
||||
synth VoiceSynth
|
||||
store VoiceStore
|
||||
// budget 把 rune 数映射为预算(毫秒);nil 用内置公式。
|
||||
budget func(runes int) int
|
||||
// persona 解析本回合的生效人格 id,供 TTS 的发音人解析链使用
|
||||
// (发音人绑人格,链本身不动)。
|
||||
persona func(context.Context) string
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// 编译期断言:speak 就是一个普通的模型侧工具,接线方式与其他工具一致。
|
||||
var _ sdk.Tool = speakTool{}
|
||||
|
||||
// Name 返回工具标识。
|
||||
func (speakTool) Name() string { return speakToolName }
|
||||
|
||||
// Description 说明**何时**用语音。它是能力描述,不是身份/权限判定 ——
|
||||
// 谁能用语音仍由渠道层 actor 元数据 + 工具可达性标记决定。
|
||||
func (speakTool) Description() string {
|
||||
return "用语音(合成音频)把这段话念出来,让这一条回复变成声音。" +
|
||||
"适合用语音:轻声细语、撒娇、唱歌、长一点的心里话或独白、需要语气温度的表达。" +
|
||||
"适合用文本:代码、列表、链接、报错原文,以及需要被复制或搜索的内容。" +
|
||||
"语音只改变这一条回复的呈现方式,不改变你的能力或身份。" +
|
||||
"合成失败或超时会自动退回文本,调用一次就够,不要为同一句话反复重试。"
|
||||
}
|
||||
|
||||
// Parameters 返回工具输入的 JSON Schema。
|
||||
func (speakTool) Parameters() (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要念出来的话。写成可直接朗读的口语,不要带 Markdown 标记、代码块或链接。",
|
||||
},
|
||||
"emotion": map[string]any{
|
||||
"type": "string",
|
||||
"description": "可选语气微调(如「温柔」「轻声」「欢快」)。默认语气由你的人格决定," +
|
||||
"这个字段只做额外微调,不会换掉发音人。",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// speakArgs 是解析后的调用参数。runes 只算一次,预算与日志共用。
|
||||
type speakArgs struct {
|
||||
text string
|
||||
emotion string
|
||||
runes int
|
||||
}
|
||||
|
||||
// Execute 合成一段语音并落盘副本。成功返回结构化结果;失败返回 *speakError,
|
||||
// 它同时是模型能读懂的错误正文与 Result.Voice 的降级依据。
|
||||
func (t speakTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
in, err := parseSpeakArgs(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.synth == nil || t.store == nil {
|
||||
return nil, &speakError{Code: speakReasonFailed,
|
||||
Err: errors.New("speak: voice synthesis is not configured")}
|
||||
}
|
||||
budget := t.budgetMs(in.runes)
|
||||
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(budget)*time.Millisecond)
|
||||
defer cancel()
|
||||
synthCtx = t.stampVoiceContext(synthCtx, ctx, in.emotion)
|
||||
|
||||
started := time.Now()
|
||||
audio, provider, err := t.synth.TTSWithProvider(synthCtx, in.text)
|
||||
cost := int(time.Since(started) / time.Millisecond)
|
||||
if err != nil {
|
||||
return nil, t.synthesisFailed(synthCtx, err, budget, cost)
|
||||
}
|
||||
ext, extOK := speakExt(audio.Format)
|
||||
if len(audio.Data) == 0 || !extOK {
|
||||
return nil, &speakError{Code: speakReasonNoAudio, BudgetMs: budget, CostMs: cost,
|
||||
Err: fmt.Errorf("speak: unusable audio (format %q, %d bytes)", audio.Format, len(audio.Data))}
|
||||
}
|
||||
// 落盘用**父 ctx**:预算只约束上游合成,不该让已经合成好的音频因为预算
|
||||
// 到点而丢掉(写盘是本地动作,毫秒级,且副本本身就是交付物)。
|
||||
ref, err := t.store.SaveAudio(ctx, speakAudioOrigName+ext, audio.Data)
|
||||
if err != nil {
|
||||
return nil, &speakError{Code: speakReasonStoreFailed, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
t.logSynthesized(provider, in.runes, budget, cost, len(audio.Data))
|
||||
// durationMs 恒为 0:本项目**绝不估算时长**(与 voice.Copy.DurationMs 同一
|
||||
// 口径),而解析 WAV 头不在本切片范围内。0 表示「未解析」,不是零长度。
|
||||
return map[string]any{
|
||||
"name": ref.Name,
|
||||
"mime": ref.MIME,
|
||||
"size": ref.Size,
|
||||
"durationMs": int64(0),
|
||||
"budgetMs": budget,
|
||||
"costMs": cost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// stampVoiceContext 把本回合的生效人格与可选语气挂到 ctx 上:TTS 侧按人格
|
||||
// 解析发音人(链不动),emotion 只作为逐请求的语气指令透传到底层链。
|
||||
func (t speakTool) stampVoiceContext(synthCtx, turnCtx context.Context, emotion string) context.Context {
|
||||
if t.persona != nil {
|
||||
if pid := t.persona(turnCtx); pid != "" {
|
||||
synthCtx = voicetts.WithPersonaID(synthCtx, pid)
|
||||
}
|
||||
}
|
||||
if emotion != "" {
|
||||
synthCtx = mvoice.WithTTSInstruction(synthCtx, emotion)
|
||||
}
|
||||
return synthCtx
|
||||
}
|
||||
|
||||
// budgetMs 返回本次合成的超时预算;注入值非正时回落内置公式。
|
||||
func (t speakTool) budgetMs(runes int) int {
|
||||
if t.budget != nil {
|
||||
if ms := t.budget(runes); ms > 0 {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
return speakBudgetMs(runes)
|
||||
}
|
||||
|
||||
// synthesisFailed 把上游错误归一化成带原因码的结构化失败。超时判定看三处:
|
||||
// 上游自己报的 deadline、我们给的预算 ctx 已过期、或实际耗时已经吃满预算
|
||||
// (上游把超时包成自己的错误类型时只有最后一处能认出来)。
|
||||
func (t speakTool) synthesisFailed(synthCtx context.Context, err error, budget, cost int) error {
|
||||
code := speakReasonFailed
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
errors.Is(synthCtx.Err(), context.DeadlineExceeded) || cost >= budget {
|
||||
code = speakReasonTimeout
|
||||
}
|
||||
if t.log != nil {
|
||||
// 只记长度与耗时,**绝不记正文**(与网关出站 TTS 同一口径)。
|
||||
t.log.Warn("speak degraded to text",
|
||||
"reason", code, "budget_ms", budget, "cost_ms", cost, "err", err)
|
||||
}
|
||||
return &speakError{Code: code, BudgetMs: budget, CostMs: cost, Err: err}
|
||||
}
|
||||
|
||||
// logSynthesized 记一条成功合成。只带来源、级别名、长度与耗时,绝不带正文。
|
||||
func (t speakTool) logSynthesized(provider string, runes, budget, cost, size int) {
|
||||
if t.log == nil {
|
||||
return
|
||||
}
|
||||
t.log.Info("speak synthesized",
|
||||
"provider", provider, "runes", runes, "budget_ms", budget, "cost_ms", cost, "bytes", size)
|
||||
}
|
||||
|
||||
// speakError 是 speak 的结构化失败。Code 进 Result.Voice.FallbackReason,让
|
||||
// 「想用语音但没成」与「本来就用文本」在调用方与客户端都能区分开;预算与实际
|
||||
// 耗时让模型下一轮能判断是"该说短点"还是"上游坏了"。
|
||||
type speakError struct {
|
||||
Code string
|
||||
BudgetMs int
|
||||
CostMs int
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error 渲染成一句可读正文,随 RoleTool 消息回灌给模型。
|
||||
func (e *speakError) Error() string {
|
||||
if e.Err == nil {
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d)", e.Code, e.BudgetMs, e.CostMs)
|
||||
}
|
||||
return fmt.Sprintf("speak failed: %s (budget_ms=%d cost_ms=%d): %v", e.Code, e.BudgetMs, e.CostMs, e.Err)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is 能穿透到原因(含 tool.ErrInvalidArgs 的参数校验分类)。
|
||||
func (e *speakError) Unwrap() error { return e.Err }
|
||||
|
||||
// parseSpeakArgs 解析并校验参数。参数错误包 tool.ErrInvalidArgs,使循环把它
|
||||
// 归到 argument-validation 而不是执行失败。
|
||||
func parseSpeakArgs(args any) (speakArgs, error) {
|
||||
m, err := speakArgMap(args)
|
||||
if err != nil {
|
||||
return speakArgs{}, err
|
||||
}
|
||||
text, _ := m["text"].(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return speakArgs{}, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: text is required: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
runes := utf8.RuneCountInString(text)
|
||||
if runes > speakMaxRunes {
|
||||
return speakArgs{}, &speakError{Code: speakReasonTooLong,
|
||||
Err: fmt.Errorf("speak: text has %d runes, limit is %d: %w", runes, speakMaxRunes, tool.ErrInvalidArgs)}
|
||||
}
|
||||
emotion, _ := m["emotion"].(string)
|
||||
return speakArgs{text: text, emotion: strings.TrimSpace(emotion), runes: runes}, nil
|
||||
}
|
||||
|
||||
// speakArgMap 把调用参数归一化成 map;nil 参数视为空对象(缺 text 由调用方报)。
|
||||
func speakArgMap(args any) (map[string]any, error) {
|
||||
if args == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
m, ok := args.(map[string]any)
|
||||
if !ok {
|
||||
return nil, &speakError{Code: speakReasonInvalidArgs,
|
||||
Err: fmt.Errorf("speak: arguments must be an object: %w", tool.ErrInvalidArgs)}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// speakBudgetMs 把文本长度(rune 数)映射为合成超时预算:
|
||||
// clamp(speakBudgetBaseMs + speakBudgetPerRuneMs×runes, base, max)。
|
||||
func speakBudgetMs(runes int) int {
|
||||
if runes < 0 {
|
||||
runes = 0
|
||||
}
|
||||
ms := speakBudgetBaseMs + speakBudgetPerRuneMs*runes
|
||||
if ms > speakBudgetMaxMs {
|
||||
return speakBudgetMaxMs
|
||||
}
|
||||
if ms < speakBudgetBaseMs {
|
||||
return speakBudgetBaseMs
|
||||
}
|
||||
return ms
|
||||
}
|
||||
|
||||
// speakExt 把上游实际格式映射成落盘扩展名。空格式按 wav 命名 —— TTS 上游
|
||||
// 产物恒为 WAV(见 voice/tts/audio.go 的口径);白名单外的格式一律判不可用,
|
||||
// 绝不拿一个读不出 Content-Type 的名字落盘。
|
||||
func speakExt(format string) (string, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "":
|
||||
return "wav", true
|
||||
case "wav", "mp3", "ogg", "flac", "m4a", "webm", "amr":
|
||||
return strings.ToLower(strings.TrimSpace(format)), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// speakSpec 返回 speak 的模型面声明(工具未接线时为 nil)。
|
||||
func (l *Loop) speakSpec() *model.ToolSpec {
|
||||
if !l.voiceEnabled() {
|
||||
return nil
|
||||
}
|
||||
t := l.speakTool()
|
||||
params, err := t.Parameters()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &model.ToolSpec{Name: t.Name(), Description: t.Description(), Parameters: params}
|
||||
}
|
||||
|
||||
// speakTool 用循环注入的依赖装配一个 speak 工具值(每次装配都是廉价的)。
|
||||
func (l *Loop) speakTool() speakTool {
|
||||
return speakTool{
|
||||
synth: l.voiceSynth,
|
||||
store: l.voiceStore,
|
||||
budget: l.speakBudget,
|
||||
persona: l.personaFor,
|
||||
log: l.log,
|
||||
}
|
||||
}
|
||||
|
||||
// invokeTool 执行单个工具调用。speak 由循环自己承载(它需要本回合的人格与
|
||||
// 预算口径,且未注册进能力登记表),其余一律交给 dispatcher —— 它的超时、
|
||||
// panic 兜底与允许集软判对普通工具仍然生效。
|
||||
func (l *Loop) invokeTool(ctx context.Context, d *tool.Dispatcher, tc agent.ToolCall) (any, error) {
|
||||
if tc.Name == speakToolName && l.voiceEnabled() {
|
||||
return l.speakTool().Execute(ctx, tc.Args)
|
||||
}
|
||||
if d == nil {
|
||||
return nil, fmt.Errorf("no tool dispatcher configured for %q", tc.Name)
|
||||
}
|
||||
return d.Invoke(tool.WithLegID(ctx, ToolLegIDFrom(ctx)), tc.Name, tc.Args)
|
||||
}
|
||||
Reference in new issue
Block a user