Files
Cyrene_v2/internal/runtime/loop/request.go
T
AskaEth 1347a77d33 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.
2026-10-27 21:22:14 +08:00

196 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/capacity"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// buildRequest 从该 turn 的 agent 公开表面组装 model.Request,
// 镜像 agent.buildRequest,使流式路径的行为与
// RunStep 一致(前导指令消息、工具 schema、模型路由)。agent
// 与推理旋钮从 ctx 解析,因此切换了角色的会话
// 在其下一步就拿到新角色的提示词与预算(design/22 §4.2)。
//
// 模型路由按该 turn 的角色(persona)解析(裁决 03.4.1)。agent.buildRequest
// (非流式路径,internal/runtime/agent/request.go)仍只按消息 role 路由——
// 那条路径的 persona 接线不在本批次文件面内,已在回报中登记。
func (l *Loop) buildRequest(ctx context.Context, messages []model.Message, ag *agent.Agent, knobs LoopKnobs) model.Request {
req := model.Request{
Model: ag.Model(),
Messages: assembleMessages(ag, messages),
Temperature: temperatureOf(ag),
MaxTokens: ag.MaxTokens(),
Purpose: ag.Purpose(),
Tools: l.toolSchemas(ctx, messages),
Thinking: knobs.Thinking,
ReasoningEffort: knobs.ReasoningEffort,
}
if ag.Router() != nil {
// 按 turn 的角色(persona)路由(裁决 03.4.1:per-role 的「角色」= 人格,
// 不是 LLM 消息 role)。路由器的 persona 感知是可选的扩展接口
// (model.PersonaRouter),因此只按消息 role 路由的注入方行为不变。
if pr, ok := ag.Router().(model.PersonaRouter); ok {
if m, _ := pr.RouteRequestForPersona(l.personaFor(ctx), req); m != "" {
req.Model = m
}
return req
}
if m, _ := ag.Router().RouteRequest(req); m != "" {
req.Model = m
}
}
return req
}
// assembleMessages 在 agent 指令非空时把它们作为前导系统消息
// 前置,否则原样返回输入切片。
func assembleMessages(ag *agent.Agent, messages []model.Message) []model.Message {
instr := ag.Instructions()
if instr == "" {
return messages
}
out := make([]model.Message, 0, len(messages)+1)
out = append(out, model.TextMessage(model.RoleSystem, instr))
out = append(out, messages...)
return out
}
// temperatureOf 返回指向 agent 所配置温度的指针,把
// 零映射为 nil,使 provider 默认值生效(与 agent 包一致)。
func temperatureOf(ag *agent.Agent) *float64 {
t := ag.Temperature()
if t == 0 {
return nil
}
return &t
}
// toolSchemas 向模型广告已注册的工具 schema;未配置
// dispatcher 时为 nil。接上并启用了 capacity 判定器时,它
// 判定当前轮次并只返回该状态的允许集
// (dispatcher 之上的一个视图,见 design/14 §4):模型只看见/调用
// 允许的工具,而 Invoke 本身不被拦截。nil 或被禁用的
// 判定器保持既有行为(每个工具都被广告)。
//
// design/11 §3 安全门最后在 capacity 视图留下的结果之上运行:
// 群聊/陌生人环境中只广告其可编辑标记允许该
// 环境的工具(默认关闭),而 owner 环境保留
// 全集。安全是硬红线而非便利提示,因此它
// 只能收缩暴露面 —— 绝不重新放行被 capacity 排除的工具。
func (l *Loop) toolSchemas(ctx context.Context, messages []model.Message) []model.ToolSpec {
d := l.dispatcher()
if d == nil {
return nil
}
schemas := d.ToolSchemas()
if l.capacity == nil || !l.capacity.Enabled() {
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, schemas)))
}
names := make([]string, 0, len(schemas))
for _, s := range schemas {
names = append(names, s.Name)
}
acc := capState(ctx)
dec := l.capacity.Decide(ctx, capacity.Input{
Messages: capacityTexts(messages),
CurrentState: acc.state,
// 该 turn 所属角色的档位(裁决 14.4.3 的后半):没有它,绑定非默认
// 角色的会话只能吃到默认角色的全局兜底,别的角色写在档案里的
// `awakening` 会被解析、被校验、却静默不生效。
Awakening: l.awakeningFor(ctx),
AllToolNames: names,
Recall: l.capacityRecall(ctx, messages),
})
acc.state = dec.State
if l.onCapacity != nil && !isMutedBroadcast(ctx) {
sessionID := loopSession(ctx)
for _, ev := range dec.Events {
ev.SessionID = sessionID
l.onCapacity(ev)
}
}
allowed := make(map[string]bool, len(dec.AllowedTools))
for _, n := range dec.AllowedTools {
allowed[n] = true
}
out := make([]model.ToolSpec, 0, len(dec.AllowedTools))
for _, s := range schemas {
if allowed[s.Name] {
out = append(out, s)
}
}
return l.withSpeakSpec(l.narrowToolSchemas(ctx, l.restrictSchemas(ctx, d, out)))
}
// capacityTexts 为 capacity 判定器提取近期对话文本:
// 最后几个非 system/非 tool 的轮次,按时间顺序(最早的在前),
// 使判定器的当前轮窗口最后看到最新的用户消息。
func capacityTexts(messages []model.Message) []string {
texts := make([]string, 0, 6)
for i := len(messages) - 1; i >= 0 && len(texts) < 6; i-- {
m := messages[i]
if m.Role == model.RoleSystem || m.Role == model.RoleTool {
continue
}
if t := messageText(m); t != "" {
texts = append(texts, t)
}
}
for i, j := 0, len(texts)-1; i < j; i, j = i+1, j-1 {
texts[i], texts[j] = texts[j], texts[i]
}
return texts
}
// capacityRecallTopK 限制 capacity L4 层每轮考虑多少条
// 召回的习惯记忆。
const capacityRecallTopK = 5
// capacityRecall 为 capacity L4 习惯对齐层(design §2.2 L4)
// 检索主人的相关长期记忆,这是让判定器学到用户任务表达
// 习惯的记忆输入。它返回 nil(使 L4 保持静默)的情形包括:
// 未配置 retriever/persona —— 向后兼容 —— 或处于受限
// (群聊 / 陌生人)上下文,依据隔离红线:L4 学习主人的
// 习惯,绝不把主人敏感记忆读进群聊或陌生人的意图
// 判定(design §2.2 红线: 不读陌生人敏感)。
func (l *Loop) capacityRecall(ctx context.Context, messages []model.Message) []capacity.RecallItem {
personaID := l.personaFor(ctx)
if l.retriever == nil || personaID == "" {
return nil
}
env := sessionEnvFrom(ctx)
if env.Restricted() {
return nil
}
results, err := l.retriever.Retrieve(ctx, personaID, queryFromMessages(messages), capacityRecallTopK)
if err != nil {
l.log.Warn("capacity recall", "error", err, "env", env.ActorEnv)
return nil
}
items := make([]capacity.RecallItem, 0, len(results))
for _, r := range results {
items = append(items, capacity.RecallItem{
Content: r.Item.Content,
Scope: string(r.Item.Scope),
Kind: string(r.Item.Kind),
Tags: r.Item.Tags,
Score: r.Score,
})
}
return items
}
// decodeToolCalls 经 agent 的共享解码器从 model.Response 解码工具
// 调用,使流式与非流式路径对适配器发出的 provider 原生
// 工具调用(Anthropic tool_use / OpenAI tool_calls)以及
// 旧式 JSON 信封约定保持一致。解码出调用时
// 报告 ok=true(切片非空)。
func decodeToolCalls(resp model.Response) ([]agent.ToolCall, bool) {
return agent.ExtractToolCalls(resp)
}