docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
This commit is contained in:
AskaEth committed 2026-10-04 16:27:54 +08:00
1 parent f5cf117482
commit 3dc1232bc2
54 files changed
+1923 -1923

No files matched your search

+22 -22
View File
@@ -5,40 +5,40 @@ import (
"errors"
)
// Errors returned by the model package. Each is wrapped with %w on the way out
// so callers can test for a category while keeping the underlying cause.
// model 包返回的错误。向外传递时一律用 %w 包装,
// 以便调用方既能按类别判定,又能保留底层原因。
var (
// ErrNoModel is returned when no model name can be resolved.
// 无法解析出任何模型名时返回 ErrNoModel。
ErrNoModel = errors.New("model: no model configured")
// ErrNoSteps is returned when a fallback chain has no steps.
// 降级链没有任何步骤时返回 ErrNoSteps。
ErrNoSteps = errors.New("model: empty fallback chain")
// ErrUpstream is returned when the provider request fails (network, status,
// decode) or every fallback step fails.
// 提供商请求失败(网络、状态码、解码)或降级链的
// 每一步都失败时,返回 ErrUpstream。
ErrUpstream = errors.New("model: upstream request failed")
// ErrBadRequest is returned when the request cannot be encoded for the wire.
// 请求无法编码为线格式时返回 ErrBadRequest。
ErrBadRequest = errors.New("model: invalid request")
// ErrStream is returned when a stream ends before completion.
// 流在完成前就结束时返回 ErrStream。
ErrStream = errors.New("model: stream closed before completion")
)
// Recorder receives a CallRecord after a successfully completed LLM call. It is
// invoked by adapters so the runtime (M9 monitor/frontend) can collect usage
// without coupling to a storage backend.
// Recorder 在一次成功完成的 LLM 调用之后接收 CallRecord。
// 它由各适配器调用,好让运行时(M9 监控/前端)收集用量,
// 而不必与任何存储后端耦合。
type Recorder func(CallRecord)
// Adapter abstracts a single LLM provider. Implementations are vendor-specific
// but speak the normalized Request/Response vocabulary; a fallback Chain or a
// Router can compose them without knowing which provider is behind each one.
// Adapter 抽象单个 LLM 提供商。各实现面向具体厂商,
// 但都说归一化的 Request/Response 词汇;降级 Chain 或
// Router 都能组合它们,无需知道每个背后是哪家提供商。
type Adapter interface {
// Name returns a stable identifier for the provider/adapter.
// Name 返回提供商/适配器的稳定标识。
Name() string
// Chat performs a non-streaming completion and returns the full response.
// Chat 执行一次非流式补全并返回完整响应。
Chat(ctx context.Context, req Request, opts Options) (Response, error)
// ChatStream streams structured events via onEvent and returns the aggregated
// response (with usage when the provider reports it). onEvent may be nil to
// discard events while still aggregating. Every provider-supported segment is
// bracketed (text_start/delta/end, tool_call_start/delta/end), so the consumer
// can render each independently; segments adapters cannot yet distinguish are
// delivered as text_delta events.
// ChatStream 通过 onEvent 流出结构化事件,并返回聚合后的
// 响应(提供商上报用量时一并带上)。onEvent 可为 nil,
// 此时仍照常聚合、只是丢弃事件。提供商支持的每个片段
// 都被成对包裹(text_start/delta/end、tool_call_start/delta/end),
// 消费者因此可以独立渲染各片段;适配器尚无法区分的片段
// 以 text_delta 事件投递。
ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error)
}
+84 -84
View File
@@ -15,41 +15,41 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// Anthropic Messages (/v1/messages) wire protocol constants.
// Anthropic Messages(/v1/messages)线协议常量。
const (
// anthropicAPIVersion is the Messages API version sent on every request.
// anthropicAPIVersion 是每次请求都要发送的 Messages API 版本。
anthropicAPIVersion = "2023-06-01"
// anthropicDefaultMaxTokens is the default max_tokens sent when
// Request.MaxTokens is zero. Anthropic requires max_tokens to be present and
// positive, so a sensible default is applied rather than omitting it.
// max_tokens is shared by thinking + text, so it must leave room for both.
// anthropicDefaultMaxTokens 是 Request.MaxTokens 为零时发送的默认
// max_tokens。Anthropic 要求 max_tokens 必须存在且为正,
// 因此这里给出合理默认值而不是省略它。
// max_tokens 由思考与正文共享,必须给两者都留出空间。
anthropicDefaultMaxTokens = 8192
// anthropicDataPrefix marks a payload embedded as a data: URI.
// anthropicDataPrefix 标记以 data: URI 内嵌的负载。
anthropicDataPrefix = "data:"
// anthropicThinkingTypeEnabled is the wire value of an enabled extended-
// thinking directive.
// anthropicThinkingTypeEnabled 是「开启扩展思考」指令的
// 线取值。
anthropicThinkingTypeEnabled = "enabled"
)
// anthropicThinking is the wire form of the extended-thinking directive. It is
// omitted entirely when the caller did not ask for thinking (or asked for it to
// be off), because Anthropic treats a missing field as "no thinking". DeepSeek's
// /anthropic endpoint instead defaults to enabled, so an explicit off is sent as
// {"type":"disabled"} with no budget (design/04 §3.2 fact 2).
// anthropicThinking 是扩展思考指令的线格式。调用方未要求思考
// (或要求关闭)时整段省略,因为 Anthropic 把字段缺失视为「不思考」。
// DeepSeek 的 /anthropic 端点恰好相反、默认开启,所以显式关闭会带着
// 不带预算的 {"type":"disabled"} 发出去
// (design/04 §3.2 事实 2)。
type anthropicThinking struct {
Type string `json:"type"`
BudgetTokens int `json:"budget_tokens,omitempty"`
}
// anthropicOutputConfig is the Anthropic-format reasoning strength block. Only
// DeepSeek's /anthropic endpoint documents it (and only the effort field), so it
// is never sent to the Anthropic endpoint (design/04 §3.2 fact 6).
// anthropicOutputConfig 是 Anthropic 格式的推理强度块。只有
// DeepSeek 的 /anthropic 端点记录了它(且只有 effort 字段),所以
// 它永远不会发给 Anthropic 端点(design/04 §3.2 事实 6)。
type anthropicOutputConfig struct {
Effort string `json:"effort"`
}
// anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's
// content-block model and the system prompt lives at the request root.
// anthropicRequest 是发给 /v1/messages 的请求体。Messages 采用 Anthropic
// 的内容块模型,system 提示词位于请求根。
type anthropicRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
@@ -63,21 +63,21 @@ type anthropicRequest struct {
Stream bool `json:"stream,omitempty"`
}
// anthropicMessage is one conversation turn. Content is an array of content
// blocks (text / image for now; tool_result blocks are added by M2).
// anthropicMessage 是一个会话回合。Content 是内容块数组
// (目前为文本 / 图片;tool_result 块由 M2 补上)。
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
// anthropicTool is the wire form of a tool schema.
// anthropicTool 是工具 schema 的线格式。
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema any `json:"input_schema"`
}
// anthropicResponse is the JSON body returned by /v1/messages.
// anthropicResponse 是 /v1/messages 返回的 JSON 响应体。
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
@@ -86,8 +86,8 @@ type anthropicResponse struct {
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicBlock is a single content block in a response. For tool_use blocks the
// Input field is a fully decoded object (never a JSON string).
// anthropicBlock 是响应里的单个内容块。对 tool_use 块而言,
// Input 字段是已完全解码的对象(绝不是 JSON 字符串)。
type anthropicBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
@@ -96,17 +96,17 @@ type anthropicBlock struct {
Input map[string]any `json:"input,omitempty"`
}
// anthropicUsage reports token consumption. Anthropic reports input_tokens and
// output_tokens rather than a single total.
// anthropicUsage 上报 token 消耗。Anthropic 上报的是 input_tokens
// 与 output_tokens,而不是单一总量。
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicChatAdapter calls an Anthropic Messages (/v1/messages) endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming, speaking the provider's native
// content-block protocol so tool_use.input arrives as a structured object.
// AnthropicChatAdapter 调用 Anthropic Messages(/v1/messages)端点。
// 它由 config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
// Adapter,直接说提供商的原生内容块协议,
// 因此 tool_use.input 以结构化对象到达。
type AnthropicChatAdapter struct {
name string
baseURL string
@@ -114,34 +114,34 @@ type AnthropicChatAdapter struct {
model string
client *http.Client
recorder Recorder
// log reports reasoning adjustments and ignored directives. It is never nil:
// a discard logger tagged with this adapter's source is installed when the
// caller supplies none, so a provider decision is always traceable.
// log 上报推理调整与被忽略的指令。它永不为 nil:
// 调用方未提供时,会装入一个带有本适配器来源标记的丢弃式日志器,
// 因此提供商的每个决定都可追溯。
log *slog.Logger
}
// AnthropicOption configures an AnthropicChatAdapter.
// AnthropicOption 用于配置 AnthropicChatAdapter。
type AnthropicOption func(*AnthropicChatAdapter)
// WithAnthropicHTTPClient overrides the default HTTP client (used to inject mocks
// or a tuned transport in tests and production).
// WithAnthropicHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产
// 中注入 mock 或调优过的 transport)。
func WithAnthropicHTTPClient(c *http.Client) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.client = c }
}
// WithAnthropicRecorder wires a per-call Recorder for CallRecord accounting.
// WithAnthropicRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
func WithAnthropicRecorder(r Recorder) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.recorder = r }
}
// WithAnthropicModel overrides the adapter's default model name.
// WithAnthropicModel 覆盖适配器的默认模型名。
func WithAnthropicModel(m string) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.model = m }
}
// WithAnthropicLogger attaches the logger used to report reasoning adjustments
// (budget raised to fit max_tokens, ignored directives). A nil logger is ignored
// so the discard default stays in place.
// WithAnthropicLogger 挂上用于上报推理调整的日志器
// (为容纳 max_tokens 而抬高的预算、被忽略的指令)。nil 日志器会被忽略,
// 从而保留丢弃式的默认值。
func WithAnthropicLogger(l *slog.Logger) AnthropicOption {
return func(a *AnthropicChatAdapter) {
if l != nil {
@@ -150,8 +150,8 @@ func WithAnthropicLogger(l *slog.Logger) AnthropicOption {
}
}
// NewAnthropicChat builds an Anthropic adapter from a supplier config. baseURL is
// trimmed of any trailing slash before the /v1/messages path is appended.
// NewAnthropicChat 由供应商配置构建 Anthropic 适配器。baseURL 会先
// 去掉末尾斜杠,再拼接 /v1/messages 路径。
func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter {
a := &AnthropicChatAdapter{
name: "anthropic",
@@ -165,9 +165,9 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
log: discardLogger("runtime/model/anthropic"),
}
if kind := strings.TrimSpace(cfg.ReasoningKind); kind != "" && kind != runtimeconfig.ReasoningKindAuto {
// The anthropic protocol defines its own thinking shape, so an
// openai-shape declaration is inert here. Report it rather than letting a
// configured option be silently ineffective (裁决 03.4.2: 禁止静默无效).
// anthropic 协议自带思考形状,因此这里声明 openai
// 形状是无效的。要上报它,而不是任由一个已配置的选项
// 静默失效(裁决 03.4.2:禁止静默无效)。
a.log.Warn("reasoning.kind ignored: the anthropic protocol defines its own thinking shape",
"provider", ProviderAnthropic, "field", "reasoning.kind", "kind", kind)
}
@@ -177,14 +177,14 @@ func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicC
return a
}
// Name returns the adapter identifier.
// Name 返回适配器标识。
func (a *AnthropicChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion. A response cut off by the token
// budget with no visible output (stop_reason max_tokens and neither text nor a
// tool_use block) is retried once with a doubled budget and, if that still
// yields nothing, replaced by a non-empty placeholder so the caller never sees a
// silent empty reply.
// Chat 执行一次非流式补全。被 token 预算截断且没有任何可见输出
// (stop_reason 为 max_tokens,且既无文本也无 tool_use 块)
// 的响应,会用翻倍的预算重试一次;
// 若仍无输出,则替换为非空占位文本,
// 让调用方永远不会看到静默的空回复。
func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -210,8 +210,8 @@ func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Optio
return out, nil
}
// chatOnce performs a single non-streaming /v1/messages call and normalizes the
// wire body into a Response.
// chatOnce 执行一次非流式 /v1/messages 调用,并把线格式
// 响应体归一化为 Response。
func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Response, error) {
body, err := a.buildRequest(req, false)
if err != nil {
@@ -236,13 +236,13 @@ func (a *AnthropicChatAdapter) chatOnce(ctx context.Context, req Request) (Respo
return responseFromAnthropicWire(&wire), nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it. Text blocks are delivered as
// text_start/text_delta/text_end events; tool_use blocks are delivered as
// tool_call_start/tool_call_delta/tool_call_end so the frontend can render each
// segment independently. A budget-truncated empty stream is retried once with a
// doubled budget (thinking from the retry is suppressed to avoid a duplicate
// block) and, if still empty, closed with a non-empty placeholder text segment.
// ChatStream 通过 onEvent 流出结构化事件,并在提供商上报时
// 返回带用量的聚合响应。文本块以 text_start/text_delta/text_end
// 事件投递;tool_use 块以
// tool_call_start/tool_call_delta/tool_call_end 投递,
// 前端因此可以独立渲染各片段。被预算截断的空流会用翻倍预算重试一次
// (重试产生的思考被抑制,以免出现重复块),
// 若仍为空,则用一段非空占位文本关闭。
func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -267,8 +267,8 @@ func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEv
return out, nil
}
// streamOnce performs a single streaming /v1/messages call and aggregates its
// SSE events into a Response.
// streamOnce 执行一次流式 /v1/messages 调用,并把其 SSE
// 事件聚合为 Response。
func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
body, err := a.buildRequest(req, true)
if err != nil {
@@ -289,7 +289,7 @@ func (a *AnthropicChatAdapter) streamOnce(ctx context.Context, req Request, onEv
return scanAnthropicSSE(resp.Body, onEvent)
}
// resolveModel returns the effective model name.
// resolveModel 返回实际生效的模型名。
func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
@@ -300,7 +300,7 @@ func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
return "", ErrNoModel
}
// endpoint returns the full /v1/messages URL.
// endpoint 返回完整的 /v1/messages URL。
func (a *AnthropicChatAdapter) endpoint() string {
return a.baseURL + "/v1/messages"
}
@@ -314,16 +314,16 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
var thinking *anthropicThinking
var output *anthropicOutputConfig
if deepSeekAnthropicEndpoint(req.Model, a.baseURL) {
// A DeepSeek /anthropic endpoint ignores budget_tokens, so max_tokens is
// deliberately not raised here: raising it would misreport the budget the
// provider actually applies. Strength goes through output_config.effort
// instead (design/04 §3.2 fact 6).
// DeepSeek 的 /anthropic 端点忽略 budget_tokens,因此这里刻意
// 不抬高 max_tokens:抬高了就会错误地反映提供商实际采用的预算。
// 强度改走 output_config.effort
// (design/04 §3.2 事实 6)。
thinking, output = anthropicDeepSeekReasoning(a.log, req.Model, req.Thinking, req.ReasoningEffort)
reportForcedToolChoiceWithThinking(a.log, string(vendorDeepSeek), req.Model, req.ToolChoice, anthropicThinkingOn(thinking))
} else {
// Extended thinking shares max_tokens with the visible answer, so on the
// Anthropic endpoint the budget is resolved together with the limit it
// requires (design/04 §3.2 red line).
// 扩展思考与可见答案共享 max_tokens,所以在 Anthropic 端点上,
// 预算是与它所需要的限额一起解出的
// (design/04 §3.2 红线)。
tokenLimit, thinking = anthropicThinkingFor(req.Thinking, tokenLimit, a.log)
a.reportAnthropicEffortIgnored(req)
}
@@ -346,10 +346,10 @@ func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, e
return buf, nil
}
// reportAnthropicEffortIgnored reports a reasoning_effort on an endpoint that has
// no field for it: the Anthropic Messages protocol carries thinking strength as
// budget_tokens. It only reports — inventing a budget would change the caller's
// intent.
// reportAnthropicEffortIgnored 上报落在没有对应字段的端点上的
// reasoning_effort:Anthropic Messages 协议用 budget_tokens
// 承载思考强度。它只上报——凭空编一个预算会改变调用方的
// 意图。
func (a *AnthropicChatAdapter) reportAnthropicEffortIgnored(req Request) {
if strings.TrimSpace(req.ReasoningEffort) == "" {
return
@@ -393,10 +393,10 @@ func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int)
})
}
// responseFromAnthropicWire converts a non-streaming /v1/messages response into a
// normalized Response. Each text block contributes to the assistant content; each
// tool_use block becomes a ToolCall whose Args is taken directly from the decoded
// input object.
// responseFromAnthropicWire 把非流式 /v1/messages 响应转换为
// 归一化的 Response。每个文本块汇入助手内容;
// 每个 tool_use 块变成一个 ToolCall,其 Args 直接取自已解码的
// input 对象。
func responseFromAnthropicWire(wire *anthropicResponse) Response {
out := Response{Model: wire.Model}
var b strings.Builder
@@ -421,9 +421,9 @@ func responseFromAnthropicWire(wire *anthropicResponse) Response {
return out
}
// AnthropicToolResult builds the content block used to feed a tool result back to
// Anthropic (role: user, first content element). toolUseID must equal the id of a
// previous tool_use block; isError marks an execution failure.
// AnthropicToolResult 构建把工具结果回喂给 Anthropic 的内容块
// (role: user,第一个内容元素)。toolUseID 必须等于此前某个
// tool_use 块的 id;isError 标记执行失败。
func AnthropicToolResult(toolUseID, content string, isError bool) map[string]any {
return map[string]any{
"type": "tool_result",
+10 -10
View File
@@ -10,11 +10,11 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking guards the fix
// for thinking models whose reasoning shares the max_tokens budget with the
// visible answer: the zero-value default must leave room for both. It also
// asserts an explicit Request.MaxTokens is never overridden by that default
// (the existing 64-token case in anthropic_test.go is the compatibility proof).
// TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking 守护思考
// 模型的修复:其推理与可见答案共享 max_tokens 预算时,
// 零值默认必须给两者都留出空间。它还断言显式的
// Request.MaxTokens 绝不会被该默认覆盖
// (anthropic_test.go 里既有的 64-token 用例就是兼容性证明)。
func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -33,11 +33,11 @@ func TestAnthropicChat_Chat_DefaultMaxTokensLargeEnoughForThinking(t *testing.T)
}
}
// TestAnthropicChat_Chat_LongThinkingThenShortText asserts a response whose
// thinking block is far longer than its text block still surfaces the text and
// never leaks the thinking into Content (the thinking-is-not-the-answer red
// line). This is the shape a thinking model produces when the budget is large
// enough, versus the truncation the old 1024 cap caused.
// TestAnthropicChat_Chat_LongThinkingThenShortText 断言思考块
// 远长于文本块的响应仍会浮出文本,且
// 绝不把思考泄漏进 Content(思考不是答案的
// 红线)。这就是预算足够大时思考模型产出的形状,
// 与旧 1024 上限造成的截断形成对照。
func TestAnthropicChat_Chat_LongThinkingThenShortText(t *testing.T) {
t.Parallel()
thinking := strings.Repeat("推理", 1500) // 3000 runes, far longer than the answer
@@ -9,10 +9,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// TestDeepSeekAnthropicEndpoint_Classification pins the endpoint detection: a
// DeepSeek /anthropic URL must be recognized even when the configured model name
// is not a DeepSeek one (the service silently maps unknown names onto
// deepseek-flash, so the name is not a reliable signal there).
// TestDeepSeekAnthropicEndpoint_Classification 钉住端点检测:
// 即便配置的模型名不是 DeepSeek 的,也必须认出
// DeepSeek /anthropic URL(该服务会把未知名称静默映射到
// deepseek-flash,所以那里模型名不是可靠信号)。
func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -33,18 +33,18 @@ func TestDeepSeekAnthropicEndpoint_Classification(t *testing.T) {
}
}
// newDeepSeekAnthropicAdapter builds an Anthropic-protocol adapter whose model
// name identifies DeepSeek, so the DeepSeek /anthropic branch is exercised while
// the request still goes to a local stub.
// newDeepSeekAnthropicAdapter 构建一个 Anthropic 协议适配器,其模型
// 名标识 DeepSeek,从而走到 DeepSeek /anthropic 分支,
// 同时请求仍发往本地 stub。
func newDeepSeekAnthropicAdapter(t *testing.T, ch chan<- anthropicCaptured, opts ...AnthropicOption) *AnthropicChatAdapter {
t.Helper()
srv := newAnthropicServer(t, http.StatusOK, "application/json", anthropicTextResponse, ch)
return NewAnthropicChat(config.LLMConfig{AnthropicBaseURL: srv.URL, AnthropicModel: deepSeekModel}, opts...)
}
// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens asserts the max_tokens raise
// stays on the Anthropic endpoint: DeepSeek's /anthropic ignores budget_tokens, so
// raising the limit there would misreport the budget the provider applies.
// TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens 断言抬高 max_tokens
// 只留在 Anthropic 端点:DeepSeek 的 /anthropic 忽略 budget_tokens,所以在
// 那里抬高限额会错误反映提供商采用的预算。
func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -79,9 +79,9 @@ func TestAnthropicDeepSeek_BudgetDoesNotRaiseMaxTokens(t *testing.T) {
}
}
// TestAnthropicOfficial_BudgetStillRaisesMaxTokens is the counterpart guard: on
// the Anthropic endpoint the design red line ("budget must not eat the answer")
// still holds.
// TestAnthropicOfficial_BudgetStillRaisesMaxTokens 是对位守护:在
// Anthropic 端点上,设计红线(「预算不得吃光答案」)
// 依然成立。
func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -110,9 +110,9 @@ func TestAnthropicOfficial_BudgetStillRaisesMaxTokens(t *testing.T) {
}
}
// TestAnthropicDeepSeek_OutputConfigEffort asserts the DeepSeek /anthropic
// strength path exists: the effort becomes output_config.effort, and the aliases
// are normalized to the tiers the service applies.
// TestAnthropicDeepSeek_OutputConfigEffort 断言 DeepSeek /anthropic 的
// 强度路径存在:effort 变成 output_config.effort,别名
// 被归一化为服务采用的档位。
func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
@@ -148,8 +148,8 @@ func TestAnthropicDeepSeek_OutputConfigEffort(t *testing.T) {
}
}
// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped asserts an unrecognized
// effort is dropped and reported rather than approximated.
// TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped 断言无法识别的
// effort 会被丢弃并上报,而不是做近似处理。
func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -178,10 +178,10 @@ func TestAnthropicDeepSeek_OutputConfigEffortInvalidDropped(t *testing.T) {
}
}
// TestAnthropicDeepSeek_EffortNoneDisablesThinking pins the translation of the
// one effort value output_config cannot carry: DeepSeek's Anthropic format has no
// effort "none", so the off intent goes through the thinking switch — mapping it
// onto a tier would turn thinking off into thinking on.
// TestAnthropicDeepSeek_EffortNoneDisablesThinking 钉住 output_config
// 无法承载的那一个 effort 取值的翻译:DeepSeek 的 Anthropic 格式
// 没有 "none" 档,所以关闭意图经 thinking 开关转达——把它映射
// 到某个档位会把「关闭思考」变成「开启思考」。
func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -208,9 +208,9 @@ func TestAnthropicDeepSeek_EffortNoneDisablesThinking(t *testing.T) {
}
}
// TestAnthropicDeepSeek_ThinkingSwitch asserts an explicit switch is carried as
// the Anthropic-format thinking block, and that a disabled one omits the budget
// (DeepSeek ignores it anyway).
// TestAnthropicDeepSeek_ThinkingSwitch 断言显式开关以
// Anthropic 格式的 thinking 块携带,且 disabled 时不带预算
// (反正 DeepSeek 会忽略它)。
func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -253,8 +253,8 @@ func TestAnthropicDeepSeek_ThinkingSwitch(t *testing.T) {
}
}
// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled keeps the "off wins"
// rule on the Anthropic path too: output_config.effort must not re-open thinking.
// TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled 在 Anthropic 路径
// 上也守住「关闭优先」规则:output_config.effort 不得重新打开思考。
func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -281,9 +281,9 @@ func TestAnthropicDeepSeek_EffortDroppedWhenThinkingDisabled(t *testing.T) {
}
}
// TestAnthropicOfficial_ReasoningEffortReportedIgnored asserts a reasoning_effort
// aimed at the Anthropic endpoint is reported instead of silently disappearing —
// the Messages protocol has no such field.
// TestAnthropicOfficial_ReasoningEffortReportedIgnored 断言落在
// Anthropic 端点上的 reasoning_effort 会被上报,而不是静默消失——
// Messages 协议没有这个字段。
func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -313,9 +313,9 @@ func TestAnthropicOfficial_ReasoningEffortReportedIgnored(t *testing.T) {
}
}
// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns covers the DeepSeek 400
// trap on the Anthropic path: the adapter warns and still sends the directive —
// it never closes thinking on its own.
// TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns 覆盖 Anthropic 路径
// 上的 DeepSeek 400 陷阱:适配器会警告但仍发送该指令——
// 它绝不自行关闭思考。
func TestAnthropicDeepSeek_ForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
+28 -28
View File
@@ -5,11 +5,11 @@ import (
"strings"
)
// buildAnthropicMessages splits a normalized message list into a top-level system
// string and the remaining messages in Anthropic's content-block form. All
// RoleSystem messages are concatenated into a single system prompt (Anthropic
// carries it at the request root, not in messages); messages with no content
// parts are dropped because Anthropic rejects empty content.
// buildAnthropicMessages 把归一化消息列表拆成顶层的 system
// 字符串与其余按 Anthropic 内容块形式表示的消息。所有
// RoleSystem 消息拼接成单个 system 提示词(Anthropic
// 把它放在请求根而非 messages 里);没有内容片段的消息
// 会被丢弃,因为 Anthropic 拒绝空内容。
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
@@ -35,9 +35,9 @@ func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error)
return sys.String(), out, nil
}
// messageText concatenates the text-bearing parts of a message (system prompts
// and tool-result bodies). Tool-result parts carry their body in Text, so a
// RoleTool turn's content serializes correctly for OpenAI's tool message.
// messageText 拼接消息中带文本的片段(system 提示词
// 与工具结果正文)。工具结果片段把正文放在 Text 里,
// 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 tool message。
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
@@ -48,23 +48,23 @@ func messageText(m Message) string {
return b.String()
}
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
// messages are extracted to the top-level system field and never reach here.
// anthropicRole 把归一化的 Role 映射到 Anthropic 的角色词汇。
// system 消息已被抽取到顶层 system 字段,不会走到这里。
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user, tool and any future roles all land on the user turn; tool_result
// blocks (M2) belong to the user role in Anthropic.
// user、tool 以及未来任何角色都落在 user 回合上;
// tool_result 块(M2)在 Anthropic 里属于 user 角色。
return "user"
}
}
// encodeAnthropicMessage turns a normalized message into Anthropic content
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
// a user plain-text turn), because Anthropic requires a tool result to arrive as
// a tool_result block carrying the tool_use_id and the is_error flag.
// encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。
// RoleTool 回合永远渲染为原生 tool_result 块
// (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以
// 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。
func encodeAnthropicMessage(m Message) ([]any, error) {
if m.Role == RoleTool {
return encodeAnthropicToolResult(m)
@@ -72,11 +72,11 @@ func encodeAnthropicMessage(m Message) ([]any, error) {
return encodeAnthropicContent(m.Content)
}
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
// tool_result blocks too so the turn never degrades into a plain user message.
// The tool_use_id falls back to the message-level ToolCallID when a part does not
// carry its own.
// encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。
// 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会
// 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。
// 当片段自身没有携带 tool_use_id 时,回退到消息级的
// ToolCallID。
func encodeAnthropicToolResult(m Message) ([]any, error) {
blocks := make([]any, 0, len(m.Content))
for _, p := range m.Content {
@@ -95,9 +95,9 @@ func encodeAnthropicToolResult(m Message) ([]any, error) {
return blocks, nil
}
// encodeAnthropicContent turns normalized content parts into Anthropic content
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
// parts are rendered at the message level by encodeAnthropicToolResult.
// encodeAnthropicContent 把归一化内容片段转成 Anthropic 内容块。
// 文本 / 图片 / tool_use(助手)片段在此处理;tool_result
// 片段由 encodeAnthropicToolResult 在消息级渲染。
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
@@ -137,9 +137,9 @@ func encodeAnthropicContent(parts []Content) ([]any, error) {
return blocks, nil
}
// parseAnthropicImageData extracts the media type and base64 payload from a data:
// URI. Anthropic requires a base64 image source and cannot reference a remote
// URL, so any other URI form is rejected as a bad request.
// parseAnthropicImageData 从 data: URI 中提取媒体类型与 base64
// 负载。Anthropic 要求 base64 图片源、无法引用远程
// URL,因此其它任何 URI 形式都按错误请求拒绝。
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
@@ -161,7 +161,7 @@ func parseAnthropicImageData(uri string) (string, string, error) {
return mediaType, data, nil
}
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
// buildAnthropicTools 把 ToolSpec schema 转换为 Anthropic 工具。
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
+32 -32
View File
@@ -5,27 +5,27 @@ import (
"strings"
)
// Anthropic extended-thinking protocol bounds (design/04 §3.2).
// Anthropic 扩展思考协议的边界(design/04 §3.2)。
const (
// anthropicMinThinkingBudget is the smallest budget_tokens the Messages API
// accepts for thinking.type=enabled; anything below it is rejected with a
// 400, so a lower non-zero budget is raised and reported rather than sent.
// anthropicMinThinkingBudget 是 Messages API 在 thinking.type=enabled 时
// 接受的最小 budget_tokens;低于它会被 400 拒绝,
// 因此更低但非零的预算会被抬高并上报,而不是照发。
anthropicMinThinkingBudget = 1024
// anthropicDefaultThinkingBudget is used when thinking is switched on without
// an explicit budget.
// anthropicDefaultThinkingBudget 在开启思考但未给
// 显式预算时使用。
anthropicDefaultThinkingBudget = 4096
// anthropicThinkingBodyReserve is the body room kept on top of the thinking
// budget: max_tokens is shared by thinking and the visible answer, so the
// budget must never be allowed to consume it all (design/04 §3.2 red line:
// anthropicThinkingBodyReserve 是在思考预算之上留给正文的空间:
// max_tokens 由思考与可见答案共享,所以
// 预算绝不允许把它全部吃光(design/04 §3.2 红线:
// "预算与正文不得互相吃光").
anthropicThinkingBodyReserve = 2048
)
// deepSeekAnthropicEndpoint reports whether an Anthropic-protocol request is
// really served by DeepSeek's /anthropic endpoint. The base URL is checked first
// because the endpoint — not the model name — decides whether budget_tokens is
// honoured, and a DeepSeek endpoint is commonly configured with a non-DeepSeek
// model name (the service silently maps unknown names onto deepseek-flash).
// deepSeekAnthropicEndpoint 报告一次 Anthropic 协议请求是否
// 真的由 DeepSeek 的 /anthropic 端点服务。先检查 base URL,
// 因为是端点——而不是模型名——决定 budget_tokens 是否
// 被尊重,而 DeepSeek 端点常常配着一个非 DeepSeek 的
// 模型名(该服务会把未知名称静默映射到 deepseek-flash)。
func deepSeekAnthropicEndpoint(model, baseURL string) bool {
if strings.Contains(strings.ToLower(baseURL), "deepseek") {
return true
@@ -33,16 +33,16 @@ func deepSeekAnthropicEndpoint(model, baseURL string) bool {
return reasoningVendorFor(model, baseURL) == vendorDeepSeek
}
// anthropicThinkingFor resolves the extended-thinking directive together with the
// max_tokens it requires. It returns the (possibly raised) max_tokens and the
// wire block; a nil block means "do not send thinking" (no directive, or an
// explicit off). Because max_tokens is shared by thinking and the visible answer,
// the returned budget always leaves anthropicThinkingBodyReserve for the body,
// and a request whose budget would eat the answer raises max_tokens instead — the
// adjustment is logged so it is never a silent rewrite.
// anthropicThinkingFor 解出扩展思考指令以及它所需的
// max_tokens。它返回(可能已被抬高的)max_tokens 与
// 线格式块;块为 nil 表示「不发送思考」(无指令,或
// 显式关闭)。由于 max_tokens 由思考与可见答案共享,
// 返回的预算总会为正文留下 anthropicThinkingBodyReserve,
// 而预算会吃光答案的请求则改为抬高 max_tokens——该
// 调整会被记录,因此绝不是一次静默改写。
//
// This is the Anthropic endpoint's rule only. A DeepSeek /anthropic endpoint
// ignores budget_tokens, so it must not go through here (design/04 §3.2 fact 6).
// 这条规则只适用于 Anthropic 端点。DeepSeek 的 /anthropic 端点
// 会忽略 budget_tokens,所以它绝不能走这里(design/04 §3.2 事实 6)。
func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *anthropicThinking) {
if th == nil || (th.Enable != nil && !*th.Enable) {
return maxTokens, nil
@@ -67,12 +67,12 @@ func anthropicThinkingFor(th *Thinking, maxTokens int, log *slog.Logger) (int, *
return maxTokens, &anthropicThinking{Type: anthropicThinkingTypeEnabled, BudgetTokens: budget}
}
// anthropicDeepSeekReasoning resolves the reasoning knobs for a DeepSeek
// /anthropic request. Two differences from the Anthropic endpoint drive it: that
// endpoint ignores budget_tokens, so max_tokens is left untouched (raising it
// would misreport the budget the provider actually applies), and strength is
// controlled by output_config.effort, which has no "none" value — there the "off"
// intent is carried by the thinking switch itself (design/04 §3.2 facts 2 and 6).
// anthropicDeepSeekReasoning 解出 DeepSeek /anthropic 请求的
// 推理旋钮。与 Anthropic 端点的两点差异决定了它:该
// 端点忽略 budget_tokens,所以 max_tokens 保持不动(抬高它
// 会错误反映提供商实际采用的预算),而强度由
// output_config.effort 控制,它没有 "none" 取值——在那里「关闭」
// 意图由 thinking 开关自身承载(design/04 §3.2 事实 2 与 6)。
func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, effort string) (*anthropicThinking, *anthropicOutputConfig) {
var block *anthropicThinking
if th != nil {
@@ -118,9 +118,9 @@ func anthropicDeepSeekReasoning(log *slog.Logger, model string, th *Thinking, ef
return block, &anthropicOutputConfig{Effort: tier}
}
// anthropicThinkingOn reports the effective thinking state of a resolved DeepSeek
// /anthropic request: an omitted switch leaves the provider default in place, and
// DeepSeek's default is enabled.
// anthropicThinkingOn 报告一个已解出的 DeepSeek /anthropic 请求的
// 实际思考状态:省略开关会保留提供商默认值,而
// DeepSeek 的默认是开启。
func anthropicThinkingOn(th *anthropicThinking) bool {
return th == nil || th.Type != thinkingTypeDisabled
}
+20 -20
View File
@@ -8,9 +8,9 @@ import (
"strings"
)
// anthropicStreamEvent is one SSE payload from /v1/messages with stream: true.
// Anthropic carries the event kind both in the SSE `event:` field and inside the
// JSON `type` field, so the parser keys off the JSON payload type.
// anthropicStreamEvent 是 /v1/messages(stream: true)的一个 SSE 负载。
// Anthropic 既在 SSE 的 `event:` 字段里、也在 JSON 的 `type` 字段里
// 携带事件种类,所以解析器按 JSON 负载的 type 取值。
type anthropicStreamEvent struct {
Type string `json:"type"`
Message *anthropicStartBlock `json:"message,omitempty"`
@@ -21,30 +21,30 @@ type anthropicStreamEvent struct {
Error *anthropicStreamError `json:"error,omitempty"`
}
// anthropicStartBlock is the payload of a message_start event.
// anthropicStartBlock 是 message_start 事件的负载。
type anthropicStartBlock struct {
Model string `json:"model"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicDelta is the incremental payload inside a content_block_delta event.
// The message_delta event reuses the same field name for its terminal
// stop_reason (end_turn / max_tokens / tool_use), so it is decoded here too.
// anthropicDelta 是 content_block_delta 事件里的增量负载。
// Anthropic 把 message_delta 事件的终止 stop_reason 复用了同一字段名
// (end_turn / max_tokens / tool_use),所以这里也一并解码。
type anthropicDelta struct {
Type string `json:"type"` // text_delta | input_json_delta | thinking_delta
Type string `json:"type"` // 取值:text_delta | input_json_delta | thinking_delta
Text string `json:"text,omitempty"`
PartialJSON string `json:"partial_json,omitempty"`
Thinking string `json:"thinking,omitempty"`
StopReason string `json:"stop_reason,omitempty"`
}
// anthropicStreamError is the payload of an error event.
// anthropicStreamError 是 error 事件的负载。
type anthropicStreamError struct {
Type string `json:"type"`
Message string `json:"message"`
}
// streamBlock tracks the partial state of one content block during streaming.
// streamBlock 跟踪流式过程中单个内容块的部分状态。
type streamBlock struct {
index int
typ string
@@ -55,13 +55,13 @@ type streamBlock struct {
initialArgs map[string]any
}
// scanAnthropicSSE incrementally decodes Anthropic's content-block stream. Text
// deltas are forwarded as text_delta events (segment bracketed by
// text_start/text_end); tool_use input_json_delta fragments are forwarded as
// tool_call_delta events (segment bracketed by tool_call_start/tool_call_end)
// and accumulated per block so they re-assemble into a structured Args map when
// the block stops. The stream must terminate with a message_stop event,
// otherwise ErrStream is returned.
// scanAnthropicSSE 增量解码 Anthropic 的内容块流。文本
// 增量以 text_delta 事件转发(片段由
// text_start/text_end 包起来);tool_use 的 input_json_delta 片段以
// tool_call_delta 事件转发(片段由 tool_call_start/tool_call_end 包起来),
// 并按块累积,以便块结束时重新拼装成结构化的 Args map。
// 流必须以 message_stop 事件终止,
// 否则返回 ErrStream。
func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
@@ -101,7 +101,7 @@ func scanAnthropicSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error)
return agg, nil
}
// applyAnthropicEvent folds a single stream event into the running aggregation.
// applyAnthropicEvent 把单个流事件折叠进正在运行的聚合状态。
func applyAnthropicEvent(
agg *Response,
blocks map[int]*streamBlock,
@@ -218,8 +218,8 @@ func applyAnthropicEvent(
return nil
}
// finalizeAnthropicStream assembles the aggregated Response from the accumulated
// blocks: concatenated text plus any tool_use ToolCalls derived per block.
// finalizeAnthropicStream 由累积的内容块拼装出聚合后的 Response:
// 拼接的文本,加上按块导出的 tool_use ToolCall。
func finalizeAnthropicStream(agg *Response, blocks map[int]*streamBlock, order []int, content string) {
agg.Content = content
agg.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: content}}}
+9 -9
View File
@@ -14,7 +14,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicCaptured records what an AnthropicChatAdapter sent to a stub server.
// anthropicCaptured 记录 AnthropicChatAdapter 发往 stub 服务器的内容。
type anthropicCaptured struct {
method string
path string
@@ -510,8 +510,8 @@ func TestAnthropicChat_ToolResult(t *testing.T) {
}
}
// anthropicBlockContent asserts a message renders a single Anthropic content block
// and returns it as a map for inspection.
// anthropicBlockContent 断言一条消息渲染出单个 Anthropic 内容块,
// 并把它作为 map 返回以供检查。
func anthropicBlockContent(t *testing.T, msg anthropicMessage) (map[string]any, bool) {
t.Helper()
blocks, ok := msg.Content.([]any)
@@ -540,7 +540,7 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
t.Fatalf("messages = %d, want 3", len(out))
}
// The assistant turn must carry the native tool_use block, not plain text.
// 助手回合必须携带原生 tool_use 块,而不是纯文本。
if out[1].Role != "assistant" {
t.Errorf("assistant role = %q, want assistant", out[1].Role)
}
@@ -555,8 +555,8 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
t.Errorf("tool_use input = %#v, want {city:Paris}", use["input"])
}
// The tool-result turn must be a user turn with a native tool_result block
// (the audit's core fix: never a user plain-text turn).
// 工具结果回合必须是带原生 tool_result 块的 user
// 回合(审计的核心修复:绝不是 user 纯文本回合)。
if out[2].Role != "user" {
t.Errorf("tool-result role = %q, want user", out[2].Role)
}
@@ -580,9 +580,9 @@ func TestAnthropicChat_Build_ToolResultBlock(t *testing.T) {
func TestAnthropicChat_Build_ToolResultLegacyText(t *testing.T) {
t.Parallel()
// The loop's current form: a RoleTool message carrying a plain text part and
// Message.ToolCallID. The protocol layer must still render a native tool_result
// block (not a user plain-text turn).
// loop 当前的形态:一个携带纯文本片段与
// Message.ToolCallID 的 RoleTool 消息。协议层仍必须渲染原生
// tool_result 块(而不是 user 纯文本回合)。
m := Message{Role: RoleTool, ToolCallID: "toolu_9",
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
_, out, err := buildAnthropicMessages([]Message{m})
+17 -17
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// newFallbackDispatchServer serves two chain steps from one endpoint: the
// "main" model fails so the chain moves on, every other model succeeds.
// newFallbackDispatchServer 由一个端点服务两个链步骤:
// "main" 模型失败以便链继续前进,其它所有模型成功。
func newFallbackDispatchServer(t *testing.T, ch chan<- capturedRequest) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -42,8 +42,8 @@ func chatOnce(t *testing.T, chain *Chain) {
}
}
// TestChain_Chat_GroupBudgetAppliesToPrimary asserts the active group's own
// maxTokens is applied to a request that carries no explicit budget.
// TestChain_Chat_GroupBudgetAppliesToPrimary 断言生效组自己的
// maxTokens 会作用于未携带显式预算的请求。
func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -57,8 +57,8 @@ func TestChain_Chat_GroupBudgetAppliesToPrimary(t *testing.T) {
}
}
// TestChain_Chat_ExplicitRequestWinsOverGroupBudget asserts an explicit
// Request.MaxTokens is never overridden (the small review/judge budgets).
// TestChain_Chat_ExplicitRequestWinsOverGroupBudget 断言显式的
// Request.MaxTokens 绝不会被覆盖(用于小额的评审/裁判预算)。
func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -75,8 +75,8 @@ func TestChain_Chat_ExplicitRequestWinsOverGroupBudget(t *testing.T) {
}
}
// TestChain_Chat_FallbackEndAppliesEnvBudget asserts the last-resort budget only
// reaches the final fallback step, never the primary.
// TestChain_Chat_FallbackEndAppliesEnvBudget 断言兜底预算只
// 到达最后的降级步骤,绝不到达主步骤。
func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) {
t.Parallel()
primaryCh := make(chan capturedRequest, 1)
@@ -97,8 +97,8 @@ func TestChain_Chat_FallbackEndAppliesEnvBudget(t *testing.T) {
}
}
// TestChain_Chat_GroupBudgetWinsOverFallbackEnv asserts a group maxTokens beats
// the last-resort env budget even on the final fallback step.
// TestChain_Chat_GroupBudgetWinsOverFallbackEnv 断言组的 maxTokens 胜过
// 兜底 env 预算,即便在最后的降级步骤上。
func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) {
t.Parallel()
primaryCh := make(chan capturedRequest, 1)
@@ -116,9 +116,9 @@ func TestChain_Chat_GroupBudgetWinsOverFallbackEnv(t *testing.T) {
}
}
// TestChain_Chat_SingleStepIgnoresFallbackEnv asserts a chain with no fallback
// model (one step = the primary) never gets the last-resort budget, so the
// adapter default is preserved.
// TestChain_Chat_SingleStepIgnoresFallbackEnv 断言没有降级
// 模型(单步 = 主模型)的链绝不会拿到兜底预算,因此
// 适配器默认值得以保留。
func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -132,8 +132,8 @@ func TestChain_Chat_SingleStepIgnoresFallbackEnv(t *testing.T) {
}
}
// TestRuntimeLLM_GroupMaxTokensEndToEnd proves the models.json maxTokens field
// reaches the provider request body through the real runtime stack.
// TestRuntimeLLM_GroupMaxTokensEndToEnd 证明 models.json 的 maxTokens 字段
// 经真实运行时栈到达提供商请求体。
func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) {
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
@@ -156,8 +156,8 @@ func TestRuntimeLLM_GroupMaxTokensEndToEnd(t *testing.T) {
}
}
// TestRuntimeLLM_FallbackEndAppliesEnvBudget proves LLM_FALLBACK_MAX_TOKENS is
// wired from config through buildSnapshot into the final fallback step.
// TestRuntimeLLM_FallbackEndAppliesEnvBudget 证明 LLM_FALLBACK_MAX_TOKENS
// 由配置经 buildSnapshot 接线到最后的降级步骤。
func TestRuntimeLLM_FallbackEndAppliesEnvBudget(t *testing.T) {
ch := make(chan capturedRequest, 2)
srv := newFallbackDispatchServer(t, ch)
@@ -9,21 +9,21 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// newDeepSeekAdapter builds an OpenAI-compatible adapter pointed at a stub server
// but classified as DeepSeek by its model name, so the DeepSeek mapping is
// exercised against the real request body.
// newDeepSeekAdapter 构建一个 OpenAI 兼容适配器,指向 stub 服务器
// 但按其模型名归类为 DeepSeek,从而让 DeepSeek 映射
// 针对真实请求体被验证。
func newDeepSeekAdapter(t *testing.T, ch chan<- capturedRequest, opts ...OpenAIOption) *OpenAIChatAdapter {
t.Helper()
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
return NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: deepSeekModel}, opts...)
}
// deepSeekModel is the current DeepSeek model name (the old deepseek-chat /
// deepseek-reasoner names were discontinued on 2026-07-24).
// deepSeekModel 是当前的 DeepSeek 模型名(旧的 deepseek-chat /
// deepseek-reasoner 名称已于 2026-07-24 停用)。
const deepSeekModel = "deepseek-flash"
// deepSeekThinkingType reads body.thinking.type, reporting whether the switch was
// sent at all.
// deepSeekThinkingType 读取 body.thinking.type,报告该开关是否
// 被发出。
func deepSeekThinkingType(body map[string]any) (string, bool) {
block, ok := body["thinking"].(map[string]any)
if !ok {
@@ -33,12 +33,12 @@ func deepSeekThinkingType(body map[string]any) (string, bool) {
return value, true
}
// TestOpenAIChat_DeepSeekReasoningEffortMatrix pins the official value set end to
// end at the request body: none/low/high/max pass through untouched and the four
// compatibility aliases are normalized to the tier DeepSeek applies
// (minimal→low, medium/xhigh→high, ultra→max). The old behavior — grading "none"
// down to "low" — turned thinking off into thinking on, so "none" must arrive as
// "none".
// TestOpenAIChat_DeepSeekReasoningEffortMatrix 钉住官方取值集合,端到
// 端落到请求体上:none/low/high/max 原样通过,四个
// 兼容别名被归一化为 DeepSeek 采用的档位
// (minimal→low、medium/xhigh→high、ultra→max)。旧行为——把 "none"
// 降级到 "low"——把关闭思考变成了开启思考,所以 "none" 必须
// 以 "none" 到达。
func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) {
t.Parallel()
cases := []struct{ sent, want string }{
@@ -74,9 +74,9 @@ func TestOpenAIChat_DeepSeekReasoningEffortMatrix(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped asserts an unrecognized
// value is dropped and reported — never replaced by a nearby tier, which is how
// the original "round down" logic inverted the caller's intent.
// TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped 断言无法识别的
// 取值会被丢弃并上报——绝不会被替换成邻近档位,正是那套
// 「向下取整」逻辑颠倒了调用方的意图。
func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -105,10 +105,10 @@ func TestOpenAIChat_DeepSeekReasoningEffortInvalidDropped(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekThinkingSwitch asserts the DeepSeek thinking switch is
// really sent: an explicit off must reach the wire (DeepSeek defaults to enabled,
// so staying silent would leave thinking ON), and nil must not be turned into
// either value.
// TestOpenAIChat_DeepSeekThinkingSwitch 断言 DeepSeek 思考开关
// 真的被发出:显式关闭必须到达线上(DeepSeek 默认开启,
// 所以保持沉默会让思考保持开启),而 nil 不得被变成
// 任何一个取值。
func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -147,9 +147,9 @@ func TestOpenAIChat_DeepSeekThinkingSwitch(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch asserts the
// official reasoning_effort "none" path is used verbatim when no thinking switch
// was requested — that is the documented way to close thinking.
// TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch 断言在未
// 请求 thinking 开关时,官方 reasoning_effort "none" 路径被原样
// 使用——那是关闭思考的记录在案的做法。
func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -171,9 +171,9 @@ func TestOpenAIChat_DeepSeekEffortNoneAloneKeepsTheOfficialOffSwitch(t *testing.
}
}
// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort is the conflict rule documented in
// resolveOpenAIReasoning: the provider states no precedence, so the off side wins
// and the dropped tier is reported — never silently applied.
// TestOpenAIChat_DeepSeekThinkingOffBeatsEffort 是 resolveOpenAIReasoning 里
// 记录的冲突规则:提供商未说明优先级,所以关闭一侧获胜,
// 被丢弃的档位会被上报——绝不静默套用。
func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -200,9 +200,9 @@ func TestOpenAIChat_DeepSeekThinkingOffBeatsEffort(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled covers the opposite
// conflict: reasoning_effort "none" and thinking enabled cannot both be honoured,
// so the off side wins and the request carries exactly one switch.
// TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled 覆盖相反的
// 冲突:reasoning_effort "none" 与 thinking 开启无法同时被
// 尊重,所以关闭一侧获胜,请求只携带一个开关。
func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -229,9 +229,9 @@ func TestOpenAIChat_DeepSeekEffortNoneBeatsThinkingEnabled(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored asserts a token budget
// (an Anthropic-style directive) is reported as unmappable on the DeepSeek
// chat-completions endpoint instead of opening or closing the switch by guess.
// TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored 断言 token 预算
// (Anthropic 风格的指令)在 DeepSeek chat-completions 端点上
// 会被上报为无法映射,而不是靠猜去开或关开关。
func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -257,10 +257,10 @@ func TestOpenAIChat_DeepSeekThinkingBudgetReportedIgnored(t *testing.T) {
}
}
// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns covers the DeepSeek
// 400 trap: a forcing tool_choice while thinking is on is rejected, and thinking
// is on by default. The adapter only warns — it must never close thinking by
// itself.
// TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns 覆盖 DeepSeek
// 400 陷阱:思考开启时强制型 tool_choice 会被拒绝,而思考
// 默认就是开启。适配器只警告——它绝不能自行
// 关闭思考。
func TestOpenAIChat_DeepSeekForcedToolChoiceWithThinkingWarns(t *testing.T) {
t.Parallel()
cases := []struct {
+43 -43
View File
@@ -9,23 +9,23 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names accepted by the provider-aware factory. These are the only two
// protocols the factory understands today; adding a vendor means adding a case
// here plus a matching adapter, not touching the rest of the package.
// 提供商感知工厂接受的提供商名。这是工厂目前理解的仅有的
// 两种协议;新增厂商意味着在这里加一个 case
// 再加一个配套适配器,而不必改动包内其它部分。
const (
// ProviderOpenAI selects the OpenAI-compatible /chat/completions protocol.
// ProviderOpenAI 选择 OpenAI 兼容的 /chat/completions 协议。
ProviderOpenAI = "openai"
// ProviderAnthropic selects the Anthropic Messages /v1/messages protocol.
// ProviderAnthropic 选择 Anthropic Messages 的 /v1/messages 协议。
ProviderAnthropic = "anthropic"
)
// ErrUnknownProvider is returned by the provider-aware factory when the
// configured provider name is not recognized.
// 配置的提供商名无法识别时,由提供商感知工厂返回
// ErrUnknownProvider。
var ErrUnknownProvider = errors.New("model: unknown provider")
// NewChatAdapter builds a single provider adapter selected by cfg.Provider. The
// empty provider defaults to openai for backward compatibility. An unrecognized
// provider returns ErrUnknownProvider.
// NewChatAdapter 按 cfg.Provider 构建单个提供商适配器。
// 提供商为空时为向后兼容默认用 openai。无法识别的
// 提供商返回 ErrUnknownProvider。
func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
switch cfg.Provider {
case ProviderOpenAI, "":
@@ -37,25 +37,25 @@ func NewChatAdapter(cfg config.LLMConfig) (Adapter, error) {
}
}
// NewChatChain builds a fallback Chain for the configured provider: the primary
// model first, then the provider's fallback model when set and distinct. Each
// step is served by an adapter of the selected protocol. The empty provider
// defaults to openai. An unrecognized provider returns ErrUnknownProvider.
// NewChatChain 为配置的提供商构建降级 Chain:先是主
// 模型,再是提供商已设置且不同的降级模型。每一步
// 都由所选协议的适配器服务。提供商为空
// 时默认用 openai。无法识别的提供商返回 ErrUnknownProvider。
func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
return NewChatChainWithLog(cfg, nil)
}
// NewChatChainWithLog is NewChatChain with a logger attached to every adapter it
// builds, so a provider-level reasoning decision (a thinking budget raised to fit
// max_tokens, a reasoning_effort value downgraded or ignored) is reported with
// its module source. A nil logger leaves the adapters' discard default in place.
// NewChatChainWithLog 是 NewChatChain,并为它构建的每个适配器
// 挂上日志器,好让提供商层面的推理决策(为容纳
// max_tokens 而抬高的思考预算、被降级或忽略的 reasoning_effort 值)
// 带着模块来源上报。nil 日志器则保留适配器的丢弃式默认值。
//
// It is also the only builder that appends the optional local third rung
// (裁决 03.4.3, primary → fallback → local), because that rung may speak a
// different protocol than the active group and therefore needs the raw logger to
// build its own adapter. It is appended only when the local rung is enabled AND
// fully configured; otherwise the chain keeps its two-level shape and the reason
// is reported.
// 它也是唯一会追加可选本地第三级(local rung)的构建器
// (裁决 03.4.3,primary → fallback → local),因为那一级可能说
// 与生效组不同的协议,因此需要原始日志器来
// 构建自己的适配器。只有当本地级已启用且完全
// 配置好时才会追加;否则链保持两级形状,并
// 上报原因。
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
var chain *Chain
var err error
@@ -76,16 +76,16 @@ func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error)
return chain, nil
}
// localChainStep builds the local third degradation rung as a chain step. It
// returns ok=false (and, when the rung was asked for but is incomplete, a WARN
// naming the missing field) when the rung is off, unconfigured or its provider is
// unknown — a half-configured step would only ever fail, which is worse than not
// having it.
// localChainStep 把本地第三级降级构造成一个链步骤。
// 当该级关闭、未配置或其提供商未知时,返回 ok=false
// (若该级被要求但配置不全,则附一条点出缺失字段的 WARN)
// ——一个半配置的步骤只会失败,那还不如没有
// 它。
func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
local := cfg.Local
// Configured() already requires Enabled, so the rung is off by default and a
// disabled rung can never become a chain step. The warning is emitted only
// when the rung was actually asked for but is incomplete.
// Configured() 本身已要求 Enabled,所以该级默认关闭,
// 被禁用的级永远不会成为链步骤。只有当该级确实被
// 要求、但配置不全时才发出该警告。
if !local.Configured() {
if local.Enabled {
warn(log, "local model rung enabled but not configured; skipping it",
@@ -112,8 +112,8 @@ func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
}
}
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
// Each step is an OpenAIChatAdapter against the configured endpoint.
// newOpenAIChain 由 OpenAI 兼容配置块构建降级链。
// 每一步都是针对该端点的 OpenAIChatAdapter。
func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
if cfg.BaseURL == "" || cfg.Model == "" {
return nil, ErrNoModel
@@ -125,8 +125,8 @@ func newOpenAIChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error)
return NewChain(steps...), nil
}
// newAnthropicChain builds a fallback chain from the Anthropic config block. Each
// step is an AnthropicChatAdapter against the configured /v1/messages endpoint.
// newAnthropicChain 由 Anthropic 配置块构建降级链。每一步
// 都是针对该 /v1/messages 端点的 AnthropicChatAdapter。
func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, error) {
if cfg.AnthropicBaseURL == "" || cfg.AnthropicModel == "" {
return nil, ErrNoModel
@@ -138,9 +138,9 @@ func newAnthropicChain(cfg config.LLMConfig, opts ...AnthropicOption) (*Chain, e
return NewChain(steps...), nil
}
// DefaultModelForProvider returns the primary model name for the configured
// provider: the flat OpenAI Model for "openai", the Anthropic model for
// "anthropic". Used by callers (e.g. cmd/cyrene) to seed a Router's default.
// DefaultModelForProvider 返回配置提供商的主模型名:
// "openai" 取扁平配置里的 OpenAI Model,"anthropic" 取 Anthropic 模型。
// 调用方(如 cmd/cyrene)用它播种 Router 的默认值。
func DefaultModelForProvider(cfg config.LLMConfig) string {
if cfg.Provider == ProviderAnthropic {
return cfg.AnthropicModel
@@ -148,10 +148,10 @@ func DefaultModelForProvider(cfg config.LLMConfig) string {
return cfg.Model
}
// warn reports through the injected logger, falling back to a discard logger that
// still carries the module source so an unsourced record can never be emitted
// (project rule §10). It exists because a chain may be built without a logger
// (NewChatChain) while a skipped rung must still be reported.
// warn 通过注入的日志器上报,降级到一个仍带有模块来源的丢弃式
// 日志器,因此绝不会产生无来源日志
// (项目铁律 §10)。它之所以存在,是因为链可能在没有日志器
// 的情况下构建(NewChatChain),而被跳过的级仍须上报。
func warn(log *slog.Logger, msg string, args ...any) {
if log == nil {
log = discardLogger("model")
+3 -3
View File
@@ -163,9 +163,9 @@ func TestRouter_RouteProvider(t *testing.T) {
}
}
// TestChain_AnthropicFallback exercises the fallback chain against two Anthropic
// adapters: the primary step fails and the backup step succeeds, proving the
// adapter composes cleanly inside a Chain.
// TestChain_AnthropicFallback 用两个 Anthropic 适配器验证
// 降级链:主步骤失败、备用步骤成功,证明该
// 适配器能在 Chain 内干净地组合。
func TestChain_AnthropicFallback(t *testing.T) {
t.Parallel()
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+39 -39
View File
@@ -7,48 +7,48 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Step binds a model name to an Adapter for one entry in a fallback chain. An
// empty Model keeps the incoming Request.Model.
// Step 把模型名与 Adapter 绑定成降级链里的一项。
// Model 为空时沿用传入 Request.Model。
type Step struct {
Model string
Adapter Adapter
}
// Chain is an Adapter that tries its steps in order and returns the first
// successful response. It implements Adapter, so it can be used anywhere a
// single provider is expected.
// Chain 是一个按顺序尝试各步骤、返回第一个
// 成功响应的 Adapter。它实现了 Adapter,因此任何期望单个
// 提供商的地方都能使用它。
type Chain struct {
steps []Step
// groupMaxTokens is the active model group's own budget (models.json
// maxTokens). When > 0 it applies to every step of the group's chain and
// wins over fallbackMaxTokens.
// groupMaxTokens 是生效模型组自己的预算(models.json
// maxTokens)。大于 0 时作用于该组链的每一步,
// 并优先于 fallbackMaxTokens。
groupMaxTokens int
// fallbackMaxTokens is the last-resort budget (LLM_FALLBACK_MAX_TOKENS). It
// only applies to the final step of a chain that actually has a fallback.
// fallbackMaxTokens 是最后的兜底预算(LLM_FALLBACK_MAX_TOKENS)。
// 它只作用于确实有降级步骤的链的最后一个步骤。
fallbackMaxTokens int
}
// NewChain builds a fallback chain from the given steps.
// NewChain 由给定步骤构建降级链。
func NewChain(steps ...Step) *Chain {
return &Chain{steps: steps}
}
// WithTokenBudget sets the chain's token budgets: the active group's own budget
// and the last-resort fallback budget. It must be called before the chain is
// published to concurrent callers (the runtime builds a fresh chain per
// snapshot and swaps it in atomically), so the fields are never mutated in
// flight.
// WithTokenBudget 设置链的 token 预算:生效组自己的预算
// 与最后的兜底预算。必须在链被发布给并发调用方之前调用
// (运行时为每个快照构建一条新链并原子换入),
// 快照并原子换入),所以这些字段绝不会在运行中
// 被改写。
func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain {
c.groupMaxTokens = groupMaxTokens
c.fallbackMaxTokens = fallbackMaxTokens
return c
}
// stepModel picks the model name for step idx of the chain. The primary step
// prefers the request's routed model — the per-persona / per-request decision the
// Router produced — over the group's configured model, so a routing decision is
// never silently discarded. Every fallback step pins its own configured model, so
// a degradation never re-sends the primary model name to a different endpoint
// stepModel 选出链上第 idx 步的模型名。主步骤
// 优先用请求中被路由的模型——即 Router 产出的按角色/按请求
// 决策——而不是组里配置的模型,这样一个路由决策
// 绝不会被静默丢弃。每个降级步骤都钉住自己配置的模型,
// 因此一次降级绝不会把主模型名重发给另一个端点。
// (裁决 03.4.1: per-persona 路由必须真的落到请求上).
func stepModel(req Request, idx int, configured string) string {
if idx == 0 && req.Model != "" {
@@ -57,12 +57,12 @@ func stepModel(req Request, idx int, configured string) string {
return configured
}
// applyBudget resolves the effective MaxTokens for step idx, honoring the
// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the
// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort
// budget only applies when the chain really has a fallback step (len > 1) and
// only to that final step; a single-step chain is a primary model, not a
// fallback, so its adapter default is left untouched.
// applyBudget 解出第 idx 步实际生效的 MaxTokens,遵循
// 优先级:显式的 Request.MaxTokens > 组自己的 MaxTokens >
// 兜底的 LLM_FALLBACK_MAX_TOKENS > 适配器默认值。兜底
// 预算只在链确实有降级步骤(len > 1)时生效,
// 且只作用于最后那一步;单步链就是主模型、并非
// 降级,所以它的适配器默认值保持不动。
func (c *Chain) applyBudget(req Request, idx int) Request {
if req.MaxTokens != 0 {
return req
@@ -77,20 +77,20 @@ func (c *Chain) applyBudget(req Request, idx int) Request {
return req
}
// NewOpenAIChatChain builds a fallback chain from a single supplier config: the
// primary model first, then cfg.Fallback (when set and distinct), both served
// by an OpenAIChatAdapter against the same OpenAI-compatible endpoint. It
// delegates to the provider-aware builder so the OpenAI path and the factory stay
// in sync. It returns ErrNoModel when no base URL or primary model is configured.
// NewOpenAIChatChain 由单份供应商配置构建降级链:先
// 主模型,再 cfg.Fallback(已设置且不同时),两者都由
// 同一个 OpenAI 兼容端点上的 OpenAIChatAdapter 服务。它
// 委托给提供商感知的构建器,使 OpenAI 路径与工厂保持
// 一致。未配置 base URL 或主模型时返回 ErrNoModel。
func NewOpenAIChatChain(cfg config.LLMConfig, opts ...OpenAIOption) (*Chain, error) {
return newOpenAIChain(cfg, opts...)
}
// Name returns the chain identifier.
// Name 返回链标识。
func (c *Chain) Name() string { return "fallback" }
// Chat tries each step in order and returns the first success. If every step
// fails, the last error is returned wrapped as ErrUpstream.
// Chat 按顺序尝试每一步并返回第一个成功的结果。若每一步
// 都失败,则把最后一个错误包成 ErrUpstream 返回。
func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
if len(c.steps) == 0 {
return Response{}, ErrNoSteps
@@ -110,10 +110,10 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response,
return Response{}, fmt.Errorf("model: %w: %v", ErrUpstream, lastErr)
}
// ChatStream tries each step in order. If a step fails before delivering any
// event, the chain moves to the next step; once a step has delivered output it
// is committed, so a mid-stream failure surfaces as an error rather than mixing
// content across providers.
// ChatStream 按顺序尝试每一步。若某步在交付任何事件之前
// 就失败,链会移到下一步;一旦某步已交付输出,
// 它就被视为已提交,因此流中途的失败会作为错误浮出,而不会把
// 不同提供商的内容混在一起。
func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
if len(c.steps) == 0 {
return Response{}, ErrNoSteps
+19 -19
View File
@@ -13,10 +13,10 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// recordingServer is an OpenAI-compatible /chat/completions endpoint that records
// the model name of every request and either fails or succeeds. It backs the
// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches
// the local endpoint with the local model name, and never when the rung is off.
// recordingServer 是一个 OpenAI 兼容的 /chat/completions 端点,记录
// 每个请求的模型名,并或失败或成功。它支撑
// 本地级测试(裁决 03.4.3):该级的意义在于请求以本地模型名
// 到达本地端点,而该级关闭时绝不如此。
type recordingServer struct {
mu sync.Mutex
models []string
@@ -51,9 +51,9 @@ func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingSe
return srv, rec
}
// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level
// (primary → fallback → local) only joins the chain when it is enabled AND fully
// configured.
// TestNewChatChainWithLog_LocalRungAppended 验证第三级降级
// (primary → fallback → local)只在启用且完全
// 配置好时才加入链。
func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -88,9 +88,9 @@ func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
}
}
// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes
// over the request after the primary and fallback steps fail, with the local model
// name on the wire and the local endpoint's own base URL/API key (not the group's).
// TestChain_Chat_LocalRungServesAfterFallback 验证本地级在主
// 步骤与降级步骤都失败后真的接管请求,且线上用
// 本地模型名与本地端点自己的 base URL/API key(而非组的)。
func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
t.Parallel()
mainSrv, _ := newRecordingServer(t, true)
@@ -117,9 +117,9 @@ func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
}
}
// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the
// rung disabled the local endpoint must never be touched, even when every other
// step fails.
// TestChain_Chat_LocalRungOffNeverCalled 钉住默认关闭的红线:该级
// 禁用时绝不能触碰本地端点,即便其它所有
// 步骤都失败。
func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
t.Parallel()
mainSrv, _ := newRecordingServer(t, true)
@@ -141,14 +141,14 @@ func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
}
}
// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard
// the router's decision: the primary step sends the request's routed model, and
// the fallback step still pins its own configured model (裁决 03.4.1).
// TestChain_Chat_PrimaryStepKeepsRoutedModel 钉住链不会丢弃
// 路由器的决策:主步骤发送请求中被路由的模型,而
// 降级步骤仍钉住自己配置的模型(裁决 03.4.1)。
func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) {
t.Parallel()
// One endpoint serves both steps (the factory builds every step against the
// group's base URL): it fails for anything but the fallback model, so the
// recorded model names show which model each step actually sent.
// 一个端点同时服务两个步骤(工厂让每一步都针对
// 组的 base URL 构建):除降级模型外它都失败,所以
// 记录下来的模型名显示了每一步实际发送了哪个模型。
rec := &recordingServer{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
+149 -149
View File
@@ -1,102 +1,102 @@
// Package model provides a vendor-agnostic LLM adapter layer for Cyrene v2.
// model 包为 Cyrene v2 提供与厂商无关的 LLM 适配层。
//
// It defines a normalized Message/Request/Response vocabulary, a single
// Adapter interface implemented by OpenAIChatAdapter (which speaks the
// OpenAI-compatible /chat/completions protocol used by OpenAI, DashScope/百炼,
// local gateways and proxies), a per-role Router, an automatic fallback Chain,
// multimodal (vision) detection, and per-call CallRecord accounting.
// 它定义了归一化的 Message/Request/Response 词汇、单个
// 由 OpenAIChatAdapter 实现的 Adapter 接口(该适配器说
// OpenAI、DashScope/百炼等使用的 OpenAI 兼容 /chat/completions 协议、
// 本地网关与代理),还有按角色的 Router、自动降级 Chain、
// 多模态(视觉)检测与按调用的 CallRecord 计账。
//
// The full tool loop (schema registration, argument binding, result re-feed) is
// deferred to M2; this package only carries interface-level placeholders
// (ToolSpec) so the wire contract is ready without executing any loop.
// 完整的工具循环(schema 注册、参数绑定、结果回喂)
// 推迟到 M2;本包只携带接口级占位
// (ToolSpec),让线格式契约先就绪,而不执行任何循环。
package model
import "time"
// Role identifies who authored a message. The values are vendor-agnostic and
// translated to each provider's own role vocabulary by the adapter.
// Role 标识消息的作者。取值与厂商无关,
// 由适配器翻译成各提供商自己的角色词汇。
type Role string
const (
// RoleSystem is a system/instruction message.
// RoleSystem 是系统/指令消息。
RoleSystem Role = "system"
// RoleUser is an end-user message.
// RoleUser 是终端用户消息。
RoleUser Role = "user"
// RoleAssistant is an assistant (model) message.
// RoleAssistant 是助手(模型)消息。
RoleAssistant Role = "assistant"
// RoleTool is a tool-result message (reserved for the M2 tool loop).
// RoleTool 是工具结果消息(保留给 M2 工具循环)。
RoleTool Role = "tool"
)
// ContentType describes the kind of payload inside a Content part.
// ContentType 描述 Content 片段内负载的种类。
type ContentType string
const (
// ContentTypeText is a plain-text content part.
// ContentTypeText 是纯文本内容片段。
ContentTypeText ContentType = "text"
// ContentTypeImage is an image content part.
// ContentTypeImage 是图片内容片段。
ContentTypeImage ContentType = "image_url"
// ContentTypeToolCall is an assistant-originated tool invocation part
// (Anthropic tool_use / OpenAI tool_calls), carried on a RoleAssistant turn.
// ContentTypeToolCall 是助手发起的工具调用片段
// (Anthropic tool_use / OpenAI tool_calls),挂在 RoleAssistant 回合上。
ContentTypeToolCall ContentType = "tool_call"
// ContentTypeToolResult is a tool execution result part, carried on a RoleTool
// turn and encoded natively (Anthropic tool_result / OpenAI tool message).
// ContentTypeToolResult 是工具执行结果片段,挂在 RoleTool
// 回合上并按原生方式编码(Anthropic tool_result / OpenAI tool message)。
ContentTypeToolResult ContentType = "tool_result"
)
// Content is a single, possibly multimodal, part of a message.
// Content 是消息的单个片段,可能是多模态的一部分。
type Content struct {
// Type distinguishes a text part from an image / tool part.
// Type 区分文本片段与图片 / 工具片段。
Type ContentType
// Text is the text payload when Type == ContentTypeText, or the result body
// when Type == ContentTypeToolResult.
// Text 是 Type == ContentTypeText 时的文本负载,
// 或 Type == ContentTypeToolResult 时的结果正文。
Text string
// ImageURL is the image source when Type == ContentTypeImage. It may be a
// data: URI or an http(s):// URL.
// ImageURL 是 Type == ContentTypeImage 时的图片来源。它可以是
// data: URI 或 http(s):// URL。
ImageURL string
// ToolCallID correlates the part to a provider tool_use/tool_call id: the call
// id for ContentTypeToolCall, or the tool_use_id being answered for
// ContentTypeToolResult.
// ToolCallID 把片段关联到提供商的 tool_use/tool_call id:
// ContentTypeToolCall 对应调用 id,
// ContentTypeToolResult 对应被应答的 tool_use_id。
ToolCallID string
// ToolCallName is the invoked tool name when Type == ContentTypeToolCall.
// ToolCallName 是 Type == ContentTypeToolCall 时被调用的工具名。
ToolCallName string
// ToolArgs is the structured argument object when Type == ContentTypeToolCall.
// It is never a JSON-encoded string.
// ToolArgs 是 Type == ContentTypeToolCall 时的结构化参数对象。
// 它绝不是 JSON 编码的字符串。
ToolArgs map[string]any
// IsError marks a ContentTypeToolResult part as an execution failure so the
// provider's tool_result.is_error / error flag is set natively.
// IsError 把 ContentTypeToolResult 片段标记为执行失败,
// 以便原生设置提供商的 tool_result.is_error / error 标志。
IsError bool
}
// Message is a single conversation turn, normalized across providers.
// Message 是单个会话回合,已跨提供商归一化。
type Message struct {
Role Role
// Content carries one or more parts. A text-only message uses a single
// ContentTypeText part.
// Content 携带一个或多个片段。仅文本的消息使用单个
// ContentTypeText 片段。
Content []Content
// SenderName is the human-readable display name of the message's sender
// (design/16 §4.6). It is transport metadata used by the context-assembly
// layer to label third-party messages for the model (e.g. "[第三方 · 昵称]"),
// never a substitute for the strongly typed sender identity carried on the
// channel layer. Empty means the sender is unlabeled (the owner, or an
// unknown/unresolved sender), and callers may leave it unset.
// SenderName 是消息发送者可读的显示名
// (design/16 §4.6)。它是上下文组装层用来给模型
// 标注第三方消息(如 "[第三方 · 昵称]")的传输元数据,
// 绝不是 channel 层强类型发送者身份的替代品。
// 为空表示发送者未标注(主人,或未识别/未解析的发送者),
// 调用方可以留着不设置。
SenderName string
// ToolCallID correlates a RoleTool turn to the provider tool_use/tool_call id
// it answers, and ToolCallName is the invoked tool name carried as a
// convenience for callers that render the result. Both are set by the tool
// loop when it feeds a result back, and consumed by the adapters to emit
// native tool_result / tool-message blocks.
// ToolCallID 把 RoleTool 回合关联到它所应答的提供商
// tool_use/tool_call id,而 ToolCallName 是同时携带的
// 被调用工具名,方便渲染结果的调用方使用。两者都由工具
// 循环在回喂结果时设置,并被适配器消费以发出
// 原生 tool_result / tool-message 块。
ToolCallID string
ToolCallName string
}
// TextMessage builds a text-only message.
// TextMessage 构建仅文本的消息。
func TextMessage(role Role, text string) Message {
return Message{Role: role, Content: []Content{{Type: ContentTypeText, Text: text}}}
}
// ImageMessage builds a message carrying optional text plus an image. imageURL
// may be a data: URI or an http(s):// URL. An empty text omits the text part.
// ImageMessage 构建携带可选文本加一张图片的消息。imageURL
// 可以是 data: URI 或 http(s):// URL。文本为空则省略文本片段。
func ImageMessage(role Role, text, imageURL string) Message {
parts := make([]Content, 0, 2)
if text != "" {
@@ -106,17 +106,17 @@ func ImageMessage(role Role, text, imageURL string) Message {
return Message{Role: role, Content: parts}
}
// ToolResultMessage builds a RoleTool turn carrying the native result of one tool
// call. toolUseID must equal the provider id of the tool_use block this result
// answers; isError marks an execution failure; text is the result body.
// ToolResultMessage 构建一个 RoleTool 回合,携带某次工具调用的
// 原生结果。toolUseID 必须等于该结果所应答的 tool_use 块的
// 提供商 id;isError 标记执行失败;text 是结果正文。
func ToolResultMessage(toolUseID, text string, isError bool) Message {
return Message{Role: RoleTool, ToolCallID: toolUseID,
Content: []Content{{Type: ContentTypeToolResult, Text: text, ToolCallID: toolUseID, IsError: isError}}}
}
// AssistantToolCallMessage builds a RoleAssistant turn advertising the tool calls
// the model requested, so a subsequent RoleTool turn can be correlated to them by
// the provider (OpenAI tool_call_id / Anthropic tool_use_id).
// AssistantToolCallMessage 构建一个 RoleAssistant 回合,声明模型
// 请求过的工具调用,好让随后一个 RoleTool 回合能被提供商
// 关联回来(OpenAI tool_call_id / Anthropic tool_use_id)。
func AssistantToolCallMessage(calls []ToolCall) Message {
parts := make([]Content, 0, len(calls))
for _, c := range calls {
@@ -125,148 +125,148 @@ func AssistantToolCallMessage(calls []ToolCall) Message {
return Message{Role: RoleAssistant, Content: parts}
}
// ToolSpec is an interface-level placeholder for a tool/function schema. The
// full tool loop (registration, argument binding, result re-feed) is M2; here
// it only lets a request advertise tools to the provider in the wire contract.
// ToolSpec 是工具/函数 schema 的接口级占位。完整
// 工具循环(注册、参数绑定、结果回喂)属于 M2;这里
// 只让请求能在线格式契约里向提供商声明工具。
type ToolSpec struct {
Name string
Description string
// Parameters is the JSON Schema for the tool arguments. Populated from M2.
// Parameters 是工具参数的 JSON Schema。M2 起填入。
Parameters any
}
// ToolCall is a single assistant-originated tool invocation parsed from a
// provider's tool_use content block. Args is the structured argument object
// (never a serialized JSON string), so a tool loop can bind it directly without
// re-parsing.
// ToolCall 是从提供商的 tool_use 内容块解析出的单个
// 助手发起的工具调用。Args 是结构化参数对象
// (绝不是序列化后的 JSON 字符串),因此工具循环可以直接绑定它,
// 无需重新解析。
type ToolCall struct {
// ID is the provider tool_use/tool_call id, so a tool loop can correlate the
// resulting RoleTool message to the exact call (Anthropic tool_use_id / OpenAI
// tool_call_id). Empty when the provider emitted no id.
// ID 是提供商的 tool_use/tool_call id,工具循环因此能把
// 产生的 RoleTool 消息关联到确切的那次调用(Anthropic tool_use_id /
// OpenAI tool_call_id)。提供商未发出 id 时为空。
ID string
// Name is the tool being invoked.
// Name 是被调用的工具。
Name string
// Args are the tool arguments, decoded from the provider's structured
// tool_use.input object. It is never a JSON-encoded string.
// Args 是工具参数,由提供商的结构化
// tool_use.input 对象解码而来。它绝不是 JSON 编码的字符串。
Args map[string]any
}
// Request is a completion request, normalized across providers.
// Request 是一次补全请求,已跨提供商归一化。
type Request struct {
// Model overrides the adapter's configured default model. Empty means the
// adapter default (or the chain step model) applies.
// Model 覆盖适配器已配置的默认模型。为空表示
// 使用适配器默认值(或链步骤模型)。
Model string
Messages []Message
// Temperature is the sampling temperature. nil means the provider default.
// Temperature 是采样温度。nil 表示提供商默认值。
//
// DeepSeek notes (design/04 §3.2 facts 3 and 4): temperature takes no effect
// while thinking is on. frequency_penalty and presence_penalty are deprecated
// at the API level — "传入该参数将不会产生任何效果" — in every mode, which is
// why Request has no field for them. top_p is deliberately not exposed
// either: it only applies while thinking is on and is clamped to >= 0.95
// there, is always 1.0 otherwise, so sending a configured value would be
// silently ineffective on one side of the switch and a hard floor on the
// other.
// DeepSeek 说明(design/04 §3.2 事实 3 与 4):思考开启期间
// temperature 不生效。frequency_penalty 与 presence_penalty 已被弃用
// ——「传入该参数将不会产生任何效果」——在任何模式下都是如此,
// 这就是 Request 没有对应字段的原因。top_p 同样被刻意不暴露:
// 它只在思考开启时生效,且在那里被夹到 >= 0.95,
// 其它情况下恒为 1.0,所以发送一个配置好的值
// 会在开关的一侧静默失效,
// 在另一侧则成为硬性下限。
Temperature *float64
// MaxTokens caps the generated tokens. Zero means the provider default.
// MaxTokens 限制生成的 token 数。为零表示提供商默认值。
MaxTokens int
// Purpose labels the call for CallRecord accounting (e.g. "chat").
// Purpose 为该次调用打标签,用于 CallRecord 计账(如 "chat")。
Purpose string
// PersonaID is the persona this turn answers as — the persona-level routing
// key of 裁决 03.4.1, as opposed to the LLM message role. RuntimeLLM reads it
// to pick the persona's provider chain (models.json personaProviders, i.e. a
// cross-protocol per-persona route). Empty means "no persona binding": the
// active group's default chain serves the request, so every pre-existing
// caller behaves exactly as before.
// PersonaID 是本回合以哪个角色作答——即
// 裁决 03.4.1 所说的按角色路由的「角色」,与 LLM 消息角色不同。RuntimeLLM
// 读它来挑出该角色的提供商链(models.json 的 personaProviders,即
// 跨协议的按角色路由)。为空表示「无角色绑定」:
// 由生效组的默认链服务该请求,因此所有既有
// 调用方的行为与之前完全一致。
//
// Why a request field and not a context key: the decision happens at the
// adapter boundary that already carries the Request, and the value is
// per-call metadata of the same kind as Model/Purpose. The persona lives in
// the loop's private session-env context key, which the model package cannot
// read (model must not import loop), so a ctx carrier would have needed the
// same write anyway — with the decision hidden behind an invisible key and
// hard to assert across packages.
// 为什么是请求字段而不是 context 键:该决策发生在
// 本就携带 Request 的适配器边界上,而且这个值是
// 与 Model/Purpose 同类的按调用元数据。角色存在于
// loop 私有的会话环境 context 键里,而 model 包读不到
// 它(model 不得 import loop),所以用 ctx 载体反正也得
// 做同样的写入——只是决策被藏在一个看不见的键后面,
// 且难以跨包断言。
PersonaID string
// Tools is the interface-level placeholder for M2 function calling.
// Tools 是 M2 函数调用的接口级占位。
Tools []ToolSpec
// ToolChoice optionally directs how the provider selects a tool. It is an
// opaque provider-agnostic directive (e.g. {"type":"auto"} or {"type":"tool",
// "name":"x"}); empty means the provider default. Passed through verbatim to
// protocols that support it (Anthropic's tool_choice).
// ToolChoice 可选地指示提供商如何选择工具。它是一个
// 提供商无关的不透明指令(如 {"type":"auto"} 或 {"type":"tool",
// "name":"x"});为空表示提供商默认值。会原样传给
// 支持它的协议(Anthropic 的 tool_choice)。
//
// DeepSeek red line (design/04 §3.2 fact 5): with thinking on — its default —
// a forcing directive ("required" / "any" / a named tool) is rejected with a
// 400, so thinking must be switched off explicitly first. The adapters only
// WARN about that combination; they never switch thinking off on their own.
// DeepSeek 红线(design/04 §3.2 事实 5):思考开启时——即其默认——
// 强制型指令("required" / "any" / 具名工具)会被 400
// 拒绝,因此必须先把思考显式关掉。适配器只对这种组合
// 发 WARN;它们绝不会自行关掉思考。
ToolChoice any
// Thinking controls the provider's reasoning budget/switch (design/04 §3.2).
// nil means "do not intervene" and uses the provider default. A provider
// whose protocol has no mapping for it logs a WARN rather than silently
// ignoring it.
// Thinking 控制提供商的推理预算/开关(design/04 §3.2)。
// nil 表示「不干预」,使用提供商默认值。协议中
// 没有对应映射的提供商会记一条 WARN,而不是静默
// 忽略它。
//
// "Do not intervene" is not the same as "off": DeepSeek's thinking defaults to
// enabled, so Enable=false is what closes it there (sent as
// {"thinking":{"type":"disabled"}}).
// 「不干预」不等于「关闭」:DeepSeek 的思考默认
// 开启,所以在那里要靠 Enable=false 来关(发送为
// {"thinking":{"type":"disabled"}})。
Thinking *Thinking
// ReasoningEffort is the OpenAI-compatible reasoning strength knob
// (reasoning_effort). Empty means "do not intervene". The value is validated
// against the detected provider's whitelist; an unrecognized value is dropped
// with a WARN and never replaced by a different tier (design/04 §3.2 fact 1).
// DeepSeek additionally documents compatibility aliases (minimal→low,
// medium/xhigh→high, ultra→max), which are normalized to the tier the service
// applies.
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮
// (reasoning_effort)。为空表示「不干预」。该值会针对
// 已识别提供商的白名单校验;无法识别的值会带着 WARN
// 被丢弃,绝不会被换成别的档位(design/04 §3.2 事实 1)。
// DeepSeek 另外记录了兼容别名(minimal→low、
// medium/xhigh→high、ultra→max),它们会被归一化为服务
// 实际采用的档位。
ReasoningEffort string
}
// Thinking carries the reasoning knobs a caller wants applied (design/04 §3.2).
// Providers map the fields they actually support and report the rest; it only
// controls budget/switch, never the thought content (design/01 §2.1 red line).
// Thinking 携带调用方希望生效的推理旋钮(design/04 §3.2)。
// 提供商映射它们真正支持的字段、并上报其余字段;它只
// 控制预算/开关,绝不控制思考内容(design/01 §2.1 红线)。
type Thinking struct {
// BudgetTokens is the Anthropic thinking.budget_tokens target (the official
// semantic is a target, not a hard cap). Zero selects the adapter default.
// BudgetTokens 是 Anthropic thinking.budget_tokens 的目标值(官方
// 语义是目标,不是硬上限)。为零则选用适配器默认值。
BudgetTokens int
// Enable is an explicit on/off switch for providers that expose a boolean
// thinking flag. nil = no explicit switch.
// Enable 是显式的开/关开关,供暴露布尔
// thinking 标志的提供商使用。nil = 无显式开关。
Enable *bool
}
// Response is a normalized completion result.
// Response 是归一化的补全结果。
type Response struct {
// Model is the model that produced the response.
// Model 是产出该响应的模型。
Model string
Content string
// Message is the normalized assistant message.
// Message 是归一化的助手消息。
Message Message
// ToolCalls carries any tool_use blocks the assistant emitted. Empty when the
// response is pure text.
// ToolCalls 携带助手发出的任何 tool_use 块。响应是纯文本时为
// 空。
ToolCalls []ToolCall
// StopReason is the provider's raw stop/finish reason (Anthropic stop_reason:
// end_turn / max_tokens / tool_use; OpenAI finish_reason: stop / length /
// tool_calls), so callers can distinguish a token-budget truncation from a
// normal end. Empty when the provider reports none.
// StopReason 是提供商原始的停止/结束原因(Anthropic stop_reason:
// end_turn / max_tokens / tool_use;OpenAI finish_reason:stop / length /
// tool_calls),因此调用方能区分 token 预算截断与
// 正常结束。提供商未上报时为空。
StopReason string
// PromptTokens, CompletionTokens and TotalTokens report provider usage when
// available; zero when the provider does not return it.
// PromptTokens、CompletionTokens 与 TotalTokens 在可用时上报
// 提供商用量;提供商未返回时为零。
PromptTokens int
CompletionTokens int
TotalTokens int
}
// CallRecord describes a single successfully completed LLM call, for the M9
// monitor/frontend. A failed attempt emits no record.
// CallRecord 描述一次成功完成的 LLM 调用,供 M9
// 监控/前端使用。失败的尝试不产生记录。
type CallRecord struct {
// Model is the model that served the request.
// Model 是服务该请求的模型。
Model string
// Purpose labels what the call was for.
// Purpose 标注该次调用的用途。
Purpose string
// Tokens is the total token consumption reported by the provider.
// Tokens 是提供商上报的总 token 消耗。
Tokens int
// TookMs is the end-to-end call latency in milliseconds.
// TookMs 是端到端调用延迟,单位毫秒。
TookMs int64
}
// Options carries per-call knobs layered on top of the Request.
// Options 携带叠加在 Request 之上的按调用旋钮。
type Options struct {
// Timeout bounds a single HTTP call. Zero uses the adapter client timeout.
// Timeout 约束单次 HTTP 调用。为零则用适配器客户端的超时。
Timeout time.Duration
}
+38 -38
View File
@@ -14,25 +14,25 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Wire constraints for the OpenAI-compatible /chat/completions protocol.
// OpenAI 兼容 /chat/completions 协议的线格式约束。
const (
// sseDataPrefix marks an SSE event line carrying a JSON payload.
// sseDataPrefix 标记携带 JSON 负载的 SSE 事件行。
sseDataPrefix = "data: "
// sseDone is the terminal SSE sentinel.
// sseDone 是终止用的 SSE 哨兵。
sseDone = "[DONE]"
// sseBufInit is the initial bufio.Scanner read buffer size.
// sseBufInit 是 bufio.Scanner 的初始读缓冲大小。
sseBufInit = 64 * 1024
// maxSSELineBytes caps a single SSE event line.
// maxSSELineBytes 限制单条 SSE 事件行的长度。
maxSSELineBytes = 1024 * 1024
// errBodyCap bounds how much of an error body is read for the message.
// errBodyCap 限制为取出消息而读取的错误响应体大小。
errBodyCap = 512
// defaultHTTPTimeout bounds a single provider call when Options.Timeout is 0.
// defaultHTTPTimeout 在 Options.Timeout 为 0 时约束单次提供商调用。
defaultHTTPTimeout = 180 * time.Second
)
// OpenAIChatAdapter calls an OpenAI-compatible /chat/completions endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming.
// OpenAIChatAdapter 调用 OpenAI 兼容的 /chat/completions 端点。它由
// config.LLMConfig 构建,同时实现非流式与 token 级 SSE 流式的
// Adapter。
type OpenAIChatAdapter struct {
name string
baseURL string
@@ -40,39 +40,39 @@ type OpenAIChatAdapter struct {
model string
client *http.Client
recorder Recorder
// reasoningKind is this endpoint's configured reasoning-switch shape
// (models.json group reasoning.kind / LLM_REASONING_KIND, 裁决 03.4.2/08.4.1).
// Empty means "auto": infer the vendor from the model name/base URL and send
// nothing when the shape is ambiguous.
// reasoningKind 是本端点已配置的推理开关形状
// (models.json 组的 reasoning.kind / LLM_REASONING_KIND,裁决 03.4.2/08.4.1)。
// 为空表示 "auto":按模型名/base URL 推断厂商,
// 形状含糊时什么都不发。
reasoningKind string
// log reports reasoning_effort validation and ignored thinking directives. It
// is never nil: a discard logger tagged with this adapter's source is
// installed when the caller supplies none, so a decision is always traceable.
// log 上报 reasoning_effort 校验与被忽略的思考指令。它
// 永不为 nil:调用方未提供时,会装入一个带有本适配器来源
// 标记的丢弃式日志器,因此每个决定都可追溯。
log *slog.Logger
}
// OpenAIOption configures an OpenAIChatAdapter.
// OpenAIOption 用于配置 OpenAIChatAdapter。
type OpenAIOption func(*OpenAIChatAdapter)
// WithHTTPClient overrides the default HTTP client (used to inject mocks or a
// tuned transport in tests and production).
// WithHTTPClient 覆盖默认 HTTP 客户端(用于在测试与生产中
// 注入 mock 或调优过的 transport)。
func WithHTTPClient(c *http.Client) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.client = c }
}
// WithRecorder wires a per-call Recorder for CallRecord accounting.
// WithRecorder 装入按调用计账的 Recorder,用于 CallRecord 统计。
func WithRecorder(r Recorder) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.recorder = r }
}
// WithModel overrides the adapter's default model name.
// WithModel 覆盖适配器的默认模型名。
func WithModel(m string) OpenAIOption {
return func(a *OpenAIChatAdapter) { a.model = m }
}
// WithOpenAILogger attaches the logger used to report reasoning_effort
// validation and ignored thinking directives. A nil logger is ignored so the
// discard default stays in place.
// WithOpenAILogger 挂上用于上报 reasoning_effort 校验与
// 被忽略思考指令的日志器。nil 日志器会被忽略,
// 从而保留丢弃式的默认值。
func WithOpenAILogger(l *slog.Logger) OpenAIOption {
return func(a *OpenAIChatAdapter) {
if l != nil {
@@ -81,12 +81,12 @@ func WithOpenAILogger(l *slog.Logger) OpenAIOption {
}
}
// NewOpenAIChat builds an adapter from a supplier config. baseURL is trimmed of
// any trailing slash before the /chat/completions path is appended. The
// endpoint's configured reasoning shape (cfg.ReasoningKind) travels with the
// adapter so every chain built from the same config — the group's chain, its
// per-persona chain and the local rung — resolves its reasoning switches the
// same way.
// NewOpenAIChat 由供应商配置构建适配器。baseURL 会先去掉
// 末尾斜杠,再拼接 /chat/completions 路径。
// 端点已配置的推理形状(cfg.ReasoningKind)随
// 适配器一起走,因此由同一份配置构建的每条链——组链、
// 它的按角色链以及本地级——都以
// 相同方式解出自己的推理开关。
func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapter {
a := &OpenAIChatAdapter{
name: "openai",
@@ -106,10 +106,10 @@ func NewOpenAIChat(cfg config.LLMConfig, opts ...OpenAIOption) *OpenAIChatAdapte
return a
}
// Name returns the adapter identifier.
// Name 返回适配器标识。
func (a *OpenAIChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion.
// Chat 执行一次非流式补全。
func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -147,8 +147,8 @@ func (a *OpenAIChatAdapter) Chat(ctx context.Context, req Request, opts Options)
return out, nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it in the final chunk.
// ChatStream 通过 onEvent 流出结构化事件,并在提供商于
// 末块上报用量时返回带用量的聚合响应。
func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
@@ -184,7 +184,7 @@ func (a *OpenAIChatAdapter) ChatStream(ctx context.Context, req Request, onEvent
return out, nil
}
// withCallTimeout wraps ctx with opts.Timeout when it is positive.
// withCallTimeout 在 opts.Timeout 为正时用该超时包裹 ctx。
func withCallTimeout(parent context.Context, opts Options) (context.Context, context.CancelFunc) {
if opts.Timeout > 0 {
return context.WithTimeout(parent, opts.Timeout)
@@ -192,7 +192,7 @@ func withCallTimeout(parent context.Context, opts Options) (context.Context, con
return parent, func() {}
}
// resolveModel returns the effective model name.
// resolveModel 返回实际生效的模型名。
func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
@@ -203,7 +203,7 @@ func (a *OpenAIChatAdapter) resolveModel(req Request) (string, error) {
return "", ErrNoModel
}
// endpoint returns the full /chat/completions URL.
// endpoint 返回完整的 /chat/completions URL。
func (a *OpenAIChatAdapter) endpoint() string {
return a.baseURL + "/chat/completions"
}
+48 -48
View File
@@ -5,8 +5,8 @@ import (
"fmt"
)
// openAIChatRequest is the JSON body sent to /chat/completions. Field names
// match the provider protocol so the struct marshals directly.
// openAIChatRequest 是发给 /chat/completions 的 JSON 请求体。字段名
// 与提供商协议一致,因此该结构体可以直接序列化。
type openAIChatRequest struct {
Model string `json:"model"`
Messages []openAIMessage `json:"messages"`
@@ -15,49 +15,49 @@ type openAIChatRequest struct {
Stream bool `json:"stream,omitempty"`
StreamOptions *openAIStreamOpts `json:"stream_options,omitempty"`
Tools []openAITool `json:"tools,omitempty"`
// Thinking is DeepSeek's top-level thinking switch. It is sent only for
// DeepSeek and only from an explicit switch (or an explicit off that must be
// honoured): leaving it out keeps the provider default, which is ENABLED, so
// an omitted field is not a way to switch thinking off (design/04 §3.2
// fact 2).
// Thinking 是 DeepSeek 的顶层思考开关。它只对
// DeepSeek 发送,且只在有显式开关(或必须被
// 尊重的显式关闭)时发送:省略它会保留提供商默认值,而该默认值
// 是「开启」,所以省略字段并不是关闭思考的办法(design/04 §3.2
// 事实 2)。
Thinking *openAIThinking `json:"thinking,omitempty"`
// ReasoningEffort is the OpenAI-compatible reasoning strength knob. It is set
// only after validation against the detected vendor's whitelist (design/04
// §3.2); an unrecognized value is dropped, never turned into a different tier.
// ReasoningEffort 是 OpenAI 兼容的推理强度旋钮。它只在
// 对已识别厂商的白名单校验通过后才设置(design/04
// §3.2);无法识别的值会被丢弃,绝不会被换成别的档位。
ReasoningEffort string `json:"reasoning_effort,omitempty"`
// ChatTemplateKwargs carries the vLLM-style thinking switch
// (chat_template_kwargs.enable_thinking, design/04 §3.2 Qwen3 行). It is sent
// only to an endpoint whose provider declared reasoning.kind=vllm.
// ChatTemplateKwargs 承载 vLLM 风格的思考开关
// (chat_template_kwargs.enable_thinking,design/04 §3.2 Qwen3 行)。它只
// 发送给提供商声明了 reasoning.kind=vllm 的端点。
ChatTemplateKwargs *openAIChatTemplateKwargs `json:"chat_template_kwargs,omitempty"`
// EnableThinking carries the DashScope compatible-mode thinking switch (the
// top-level enable_thinking). It is sent only to an endpoint whose provider
// declared reasoning.kind=dashscope — the shape is never inferred, because a
// wrong shape 400s the request.
// EnableThinking 承载 DashScope 兼容模式的思考开关(
// 顶层 enable_thinking)。它只发送给提供商
// 声明了 reasoning.kind=dashscope 的端点——该形状永不推断,因为
// 形状错了会让请求 400。
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// openAIThinking is the wire form of DeepSeek's thinking switch: enabled or
// disabled. It is only ever sent to a DeepSeek endpoint.
// openAIThinking 是 DeepSeek 思考开关的线格式:enabled 或
// disabled。它只会发给 DeepSeek 端点。
type openAIThinking struct {
Type string `json:"type"`
}
// openAIChatTemplateKwargs is the wire form of the vLLM (Qwen3) thinking switch:
// the boolean lives nested under chat_template_kwargs.
// openAIChatTemplateKwargs 是 vLLM(Qwen3)思考开关的线格式:
// 该布尔值嵌在 chat_template_kwargs 之下。
type openAIChatTemplateKwargs struct {
EnableThinking *bool `json:"enable_thinking,omitempty"`
}
// openAIStreamOpts asks the provider to include usage in the final stream chunk.
// openAIStreamOpts 请求提供商在最后一个流块里带上用量。
type openAIStreamOpts struct {
IncludeUsage bool `json:"include_usage"`
}
// openAIMessage is one request message in the wire body. Content is either a
// plain string (text-only) or an array of {type,text|image_url} parts. Tool
// messages carry ToolCallID (required by OpenAI); assistant messages that
// requested functions carry ToolCalls so the following tool messages can be
// correlated.
// openAIMessage 是线格式请求体里的一条消息。Content 要么是
// 纯字符串(仅文本),要么是 {type,text|image_url} 片段数组。Tool
// 消息携带 ToolCallID(OpenAI 要求);请求过函数的助手消息
// 携带 ToolCalls,以便随后的 tool 消息能够
// 被关联。
type openAIMessage struct {
Role string `json:"role"`
Content any `json:"content"`
@@ -65,28 +65,28 @@ type openAIMessage struct {
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
}
// openAIToolCall is the wire form of one assistant tool_call in a request. OpenAI
// requires each subsequent tool message to reference the id back to these calls.
// openAIToolCall 是请求里单个助手 tool_call 的线格式。OpenAI
// 要求每条后续 tool 消息都把这些调用的 id 引用回来。
type openAIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function openAIToolCallFn `json:"function"`
}
// openAITool is the wire form of a function tool schema (M2 placeholder).
// openAITool 是函数工具 schema 的线格式(M2 占位)。
type openAITool struct {
Type string `json:"type"`
Function openAIToolFn `json:"function"`
}
// openAIToolFn carries the function definition.
// openAIToolFn 承载函数定义。
type openAIToolFn struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters any `json:"parameters,omitempty"`
}
// openAIToolCallFn is the function part of a streaming tool-call fragment.
// openAIToolCallFn 是流式工具调用片段的函数部分。
type openAIToolCallFn struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
@@ -97,12 +97,12 @@ func (a *OpenAIChatAdapter) buildRequest(req Request, stream bool) ([]byte, erro
if err != nil {
return nil, err
}
// The vendor behind an OpenAI-compatible endpoint decides which
// reasoning_effort tiers are legal and whether it has a thinking switch;
// classify it from the resolved model (and the base URL as a fallback) before
// touching the request. The endpoint's configured shape (models.json
// reasoning.kind, 裁决 03.4.2/08.4.1) wins over that classification, because
// the same wire protocol carries different switch shapes (vLLM vs DashScope).
// OpenAI 兼容端点背后的厂商决定哪些
// reasoning_effort 档位合法、以及它是否有思考开关;
// 在改动请求之前,先按已解析出的模型(并以 base URL 作降级)
// 分类。端点已声明的形状(models.json
// 的 reasoning.kind,裁决 03.4.2/08.4.1)优先于该分类,因为
// 同一线协议承载着不同的开关形状(vLLM 对 DashScope)。
vendor := reasoningVendorFor(req.Model, a.baseURL)
plan := resolveOpenAIReasoning(a.log, a.reasoningKind, vendor, req.Model, req.Thinking, req.ReasoningEffort)
reportForcedToolChoiceWithThinking(a.log, plan.provider, req.Model, req.ToolChoice, plan.thinkingOn())
@@ -140,10 +140,10 @@ func buildMessages(msgs []Message) ([]openAIMessage, error) {
return out, nil
}
// encodeOpenAIMessage renders one normalized message in OpenAI wire form. A
// RoleTool turn becomes a tool message carrying tool_call_id (required by OpenAI,
// and the audit's gap); an assistant turn that advertised tool calls carries the
// corresponding tool_calls array so the provider can correlate the tool messages.
// encodeOpenAIMessage 把一条归一化消息渲染成 OpenAI 线格式。
// RoleTool 回合变成携带 tool_call_id 的 tool 消息(OpenAI 要求,
// 也是审计指出的缺口);声明过工具调用的助手回合携带
// 对应的 tool_calls 数组,好让提供商能关联 tool 消息。
func encodeOpenAIMessage(m Message) (openAIMessage, error) {
if m.Role == RoleTool {
return openAIMessage{
@@ -159,8 +159,8 @@ func encodeOpenAIMessage(m Message) (openAIMessage, error) {
return openAIMessage{Role: string(m.Role), Content: content, ToolCalls: encodeToolCalls(m.Content)}, nil
}
// toolMessageCallID resolves the tool_call_id for a RoleTool turn: the
// message-level id, else the id carried on the first tool-result part.
// toolMessageCallID 解出 RoleTool 回合的 tool_call_id:
// 先是消息级 id,否则取第一个工具结果片段上的 id。
func toolMessageCallID(m Message) string {
if m.ToolCallID != "" {
return m.ToolCallID
@@ -173,8 +173,8 @@ func toolMessageCallID(m Message) string {
return ""
}
// encodeToolCalls builds the assistant tool_calls array from a turn's tool-call
// parts. Arguments are serialized to JSON because OpenAI expects a string.
// encodeToolCalls 由回合的工具调用片段构建助手的 tool_calls
// 数组。参数序列化为 JSON,因为 OpenAI 期望字符串。
func encodeToolCalls(parts []Content) []openAIToolCall {
calls := make([]openAIToolCall, 0, len(parts))
for _, p := range parts {
@@ -211,8 +211,8 @@ func encodeContent(parts []Content) (any, error) {
"image_url": map[string]any{"url": p.ImageURL},
})
case ContentTypeToolCall, ContentTypeToolResult:
// Tool-call and tool-result parts are encoded at the message level
// (tool_calls array / tool_call_id), never as text-image content.
// 工具调用与工具结果片段在消息级编码
// (tool_calls 数组 / tool_call_id),绝不作为文本-图片内容。
continue
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
+12 -12
View File
@@ -4,8 +4,8 @@ import (
"encoding/json"
)
// openAIChatResponse is the JSON body returned by /chat/completions, for both
// the non-streaming payload and each SSE stream chunk.
// openAIChatResponse 是 /chat/completions 返回的 JSON 体,既用于
// 非流式负载,也用于每个 SSE 流块。
type openAIChatResponse struct {
ID string `json:"id"`
Model string `json:"model"`
@@ -13,7 +13,7 @@ type openAIChatResponse struct {
Usage *openAIUsage `json:"usage,omitempty"`
}
// openAIChoice is one candidate completion.
// openAIChoice 是一个候选补全。
type openAIChoice struct {
Index int `json:"index"`
Message openAIResponseMessage `json:"message"`
@@ -21,16 +21,16 @@ type openAIChoice struct {
FinishReason string `json:"finish_reason,omitempty"`
}
// openAIResponseMessage is the assistant message inside a non-streaming choice.
// openAIResponseMessage 是非流式 choice 里的助手消息。
type openAIResponseMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []openAIToolCall `json:"tool_calls,omitempty"`
}
// openAIDelta is the incremental payload inside a stream chunk. Reasoning content
// (OpenAI o-series / DeepSeek / DashScope) arrives in ReasoningContent; streaming
// function calls arrive in ToolCalls, accumulated per fragment index.
// openAIDelta 是流块里的增量负载。推理内容
// (OpenAI o 系 / DeepSeek / DashScope)到达 ReasoningContent;流式
// 函数调用到达 ToolCalls,按片段索引累积。
type openAIDelta struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -38,17 +38,17 @@ type openAIDelta struct {
ToolCalls []openAIToolCallDelta `json:"tool_calls"`
}
// openAIToolCallDelta is one streaming function-call fragment. OpenAI emits a
// sequence of these sharing the same Index: the ID and Function.Name usually
// arrive once (in the first fragment) while Function.Arguments streams across
// fragments.
// openAIToolCallDelta 是一个流式函数调用片段。OpenAI 会发出一串
// 共享同一 Index 的此类片段:ID 与 Function.Name 通常
// 只在第一个片段里出现一次,而 Function.Arguments 跨
// 多个片段流式到达。
type openAIToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id"`
Function openAIToolCallFn `json:"function"`
}
// openAIUsage reports token consumption.
// openAIUsage 上报 token 消耗。
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
+43 -43
View File
@@ -8,9 +8,9 @@ import (
"strings"
)
// openAIToolAcc accumulates the partial state of one streaming tool call (keyed
// by the provider's tool_calls[].index) so its arguments re-assemble across the
// delta fragments. started guards a single ToolCallStart emission per block.
// openAIToolAcc 累积单个流式工具调用的部分状态(按
// 提供商的 tool_calls[].index 作键),以便其参数跨
// 增量片段重新拼装。started 保证每块只发出一次 ToolCallStart。
type openAIToolAcc struct {
id string
name string
@@ -18,8 +18,8 @@ type openAIToolAcc struct {
started bool
}
// idOrName resolves the block identifier used to correlate start/delta/end: the
// provider tool-call id when present, else the function name.
// idOrName 解出用于关联 start/delta/end 的块标识:
// 有提供商工具调用 id 时用它,否则用函数名。
func (a *openAIToolAcc) idOrName() string {
if a.id != "" {
return a.id
@@ -27,12 +27,12 @@ func (a *openAIToolAcc) idOrName() string {
return a.name
}
// openAIStreamState is the running aggregation of one streamed
// /chat/completions response: the assembled Response, the bracketing flags of the
// currently open segment, and the per-index tool-call accumulators. It is the
// OpenAI counterpart of anthropic's streamBlock bookkeeping, so both adapters
// share one parse → apply → finalize shape. emit is the nil-tolerant event sink
// (a nil onEvent simply drops every event).
// openAIStreamState 是一次流式 /chat/completions 响应的
// 运行中聚合状态:拼装好的 Response、当前打开片段的
// 包裹标志,以及按索引的工具调用累加器。它是
// Anthropic 的 streamBlock 记账的对位实现,因此两个适配器
// 共用同一套「解析 → 应用 → 收尾」形态。emit 是容忍 nil 的事件槽
// (onEvent 为 nil 时只是丢弃每个事件)。
type openAIStreamState struct {
agg Response
text strings.Builder
@@ -43,13 +43,13 @@ type openAIStreamState struct {
onEvent func(StreamEvent)
}
// scanSSE incrementally decodes stream chunks, accumulates the full body text
// with a strings.Builder (never reading the whole body at once) and captures
// usage from the final chunk. It splits each delta into its segment and brackets
// them: reasoning_content as thinking_start/delta/end, tool_calls fragments as
// tool_call_start/delta/end, and body text as text_start/delta/end. The
// accumulated tool-call arguments re-assemble across fragments into a structured
// Args map, and are attached to the returned Response alongside the text.
// scanSSE 增量解码流块,用 strings.Builder 累积完整正文
// 文本(绝不一次性读完整个响应体),并从最后一个块
// 捕获用量。它把每个增量拆到其片段并按对包裹:
// reasoning_content 作为 thinking_start/delta/end,tool_calls 片段作为
// tool_call_start/delta/end,正文文本作为 text_start/delta/end。
// 累积的工具调用参数会跨片段重新拼装成结构化的
// Args map,并与文本一起挂到返回的 Response 上。
func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, sseBufInit), maxSSELineBytes)
@@ -76,16 +76,16 @@ func scanSSE(r io.Reader, onEvent func(StreamEvent)) (Response, error) {
return finalizeOpenAIStream(st), nil
}
// emit forwards one structured event, tolerating a nil sink.
// emit 转发一个结构化事件,容忍 nil 事件槽。
func (st *openAIStreamState) emit(ev StreamEvent) {
if st.onEvent != nil {
st.onEvent(ev)
}
}
// applyChunk folds a single decoded stream chunk into the running aggregation:
// usage and stop reason are recorded even on a choice-less terminal chunk, and
// the segment delta (if any) is routed to its handler.
// applyChunk 把单个已解码的流块折叠进运行中的聚合状态:
// 用量与停止原因即使在无 choice 的终止块上也会被记录,
// 片段增量(若有)则被路由到其处理器。
func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
if u := chunk.Usage; u != nil {
st.agg.PromptTokens = u.PromptTokens
@@ -104,9 +104,9 @@ func (st *openAIStreamState) applyChunk(chunk openAIChatResponse) {
st.applyDelta(chunk.Choices[0].Delta)
}
// applyDelta routes one delta fragment to its segment handler. The precedence is
// the provider's: a reasoning fragment wins over a tool-call fragment, which wins
// over body text, so a delta never contributes to two segments at once.
// applyDelta 把一个增量片段路由到其片段处理器。优先级
// 取自提供商:推理片段压过工具调用片段,后者又压过
// 正文文本,因此一个增量绝不会同时进入两个片段。
func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
switch {
case delta.ReasoningContent != "":
@@ -122,8 +122,8 @@ func (st *openAIStreamState) applyDelta(delta *openAIDelta) {
}
}
// applyThinking forwards one reasoning fragment, opening the thinking segment on
// the first fragment only.
// applyThinking 转发一个推理片段,只在第一个
// 片段上打开 thinking 片段。
func (st *openAIStreamState) applyThinking(fragment string) {
if !st.thinkingStarted {
st.thinkingStarted = true
@@ -132,8 +132,8 @@ func (st *openAIStreamState) applyThinking(fragment string) {
st.emit(StreamEvent{Kind: StreamKindThinkingDelta, Delta: fragment})
}
// endThinking closes the open thinking segment (no-op when none is open), so a
// following tool-call or text segment starts a fresh one.
// endThinking 关闭已打开的 thinking 片段(未打开时是空操作),
// 这样随后的工具调用或文本片段会开一个新的。
func (st *openAIStreamState) endThinking() {
if !st.thinkingStarted {
return
@@ -142,9 +142,9 @@ func (st *openAIStreamState) endThinking() {
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
}
// applyToolCall folds one streaming tool-call fragment into its per-index
// accumulator, opening the block's start event once its id/name is known and
// forwarding the argument fragments as they arrive.
// applyToolCall 把一个流式工具调用片段折叠进它按索引的
// 累加器,在 id/name 已知时打开该块的 start 事件,
// 并随着参数片段到达而转发它们。
func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
acc := st.toolCalls[tc.Index]
if acc == nil {
@@ -169,8 +169,8 @@ func (st *openAIStreamState) applyToolCall(tc openAIToolCallDelta) {
st.emit(StreamEvent{Kind: StreamKindToolCallDelta, Index: tc.Index, ID: acc.idOrName(), Delta: tc.Function.Arguments})
}
// applyText appends one body-text fragment, opening the text segment on the first
// fragment only.
// applyText 追加一个正文文本片段,只在第一个
// 片段上打开文本片段。
func (st *openAIStreamState) applyText(fragment string) {
st.text.WriteString(fragment)
if !st.textStarted {
@@ -180,9 +180,9 @@ func (st *openAIStreamState) applyText(fragment string) {
st.emit(StreamEvent{Kind: StreamKindTextDelta, Delta: fragment})
}
// closeSegments emits the end event of every segment still open at stream
// termination. OpenAI has no per-block stop event, so each started tool_call
// block is closed here.
// closeSegments 在流终止时发出每个仍打开片段的结束
// 事件。OpenAI 没有按块的停止事件,所以每个已开始的 tool_call
// 块都在此关闭。
func (st *openAIStreamState) closeSegments() {
if st.thinkingStarted {
st.emit(StreamEvent{Kind: StreamKindThinkingEnd})
@@ -199,9 +199,9 @@ func (st *openAIStreamState) closeSegments() {
}
}
// assembleToolCalls decodes each accumulated tool-call argument buffer into its
// structured Args map, in provider order. Unparseable fragments degrade to an
// empty map (the call is still surfaced).
// assembleToolCalls 按提供商顺序把每个累积的工具调用参数缓冲
// 解码成结构化的 Args map。无法解析的片段降级为
// 空 map(该调用仍会浮出)。
func (st *openAIStreamState) assembleToolCalls() {
for _, idx := range st.toolOrder {
acc := st.toolCalls[idx]
@@ -218,9 +218,9 @@ func (st *openAIStreamState) assembleToolCalls() {
}
}
// finalizeOpenAIStream closes the open segments and assembles the aggregated
// Response: the concatenated body text (reasoning never leaks into it) plus the
// re-assembled tool calls.
// finalizeOpenAIStream 关闭打开的片段并拼装聚合后的
// Response:拼接的正文文本(推理永不泄漏进去)加上
// 重新拼装的工具调用。
func finalizeOpenAIStream(st *openAIStreamState) Response {
st.closeSegments()
st.assembleToolCalls()
+6 -6
View File
@@ -23,7 +23,7 @@ const okCompletion = `{
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}
}`
// capturedRequest records what an OpenAIChatAdapter sent to a stub server.
// capturedRequest 记录 OpenAIChatAdapter 发往 stub 服务器的内容。
type capturedRequest struct {
method string
path string
@@ -374,8 +374,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"])
}
// The assistant turn must carry tool_calls (so the tool message can be
// correlated by the provider) — this was previously dropped entirely.
// 助手回合必须携带 tool_calls(好让 tool 消息能被
// 提供商关联)——这一点此前被完全丢弃。
assistant, _ := msgs[1].(map[string]any)
if assistant["role"] != "assistant" {
t.Errorf("assistant role = %v, want assistant", assistant["role"])
@@ -393,7 +393,7 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn)
}
// The tool message must carry tool_call_id (the audit's gap) and the result.
// tool 消息必须携带 tool_call_id(审计指出的缺口)与结果。
tool, _ := msgs[2].(map[string]any)
if tool["role"] != "tool" {
t.Errorf("tool role = %v, want tool", tool["role"])
@@ -408,8 +408,8 @@ func TestOpenAIChat_Chat_ToolRoundTrip(t *testing.T) {
func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) {
t.Parallel()
// The loop's current form: a RoleTool message carrying a plain text part and
// Message.ToolCallID. buildMessages must emit a tool message with tool_call_id.
// loop 当前的形态:一个携带纯文本片段与
// Message.ToolCallID 的 RoleTool 消息。buildMessages 必须发出带 tool_call_id 的 tool 消息。
m := Message{Role: RoleTool, ToolCallID: "call_9",
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
msgs, err := buildMessages([]Message{m})
+19 -19
View File
@@ -89,10 +89,10 @@ func TestRuntimeLLM_PersonaProviderSelectsTheOtherProtocol(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo asserts the streaming path
// (the main chat path: loop.streamStep → adapter.ChatStream) resolves the same
// persona chain as the non-streaming one — a persona route that only worked on
// one of the two paths would be a silent half-fix.
// TestRuntimeLLM_PersonaProviderAppliesToStreamingToo 断言流式路径
// (主聊天路径:loop.streamStep → adapter.ChatStream)解析出与非流式
// 相同的那条 persona 链——只在两条路径之一生效的 persona
// 路由只能算一次静默的半修。
func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) {
t.Parallel()
anthropicCh := make(chan anthropicCaptured, 1)
@@ -123,9 +123,9 @@ func TestRuntimeLLM_PersonaProviderAppliesToStreamingToo(t *testing.T) {
}
}
// TestPersonaStack_StampsPersonaOnEveryRequest pins the carrier that lets a
// per-persona agent wire the route without the loop handing over its ctx: the
// scoped adapter view stamps PersonaID and routes the model by that persona.
// TestPersonaStack_StampsPersonaOnEveryRequest 钉住那个载体:它让
// 按 persona 的 agent 能在 loop 不交出自身 ctx 的情况下接好路由,
// 即作用域化的适配器视图打上 PersonaID 并按该 persona 路由模型。
func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, nil)
@@ -151,8 +151,8 @@ func TestPersonaStack_StampsPersonaOnEveryRequest(t *testing.T) {
}
}
// TestPersonaStack_BlankPersonaKeepsDefaultRoute asserts an unbound view is a
// pass-through: an empty persona must not turn into a binding of its own.
// TestPersonaStack_BlankPersonaKeepsDefaultRoute 断言未绑定的视图是
// 直通:空的 persona 不得变成它自己的一个绑定。
func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, nil)
@@ -177,10 +177,10 @@ func TestPersonaStack_BlankPersonaKeepsDefaultRoute(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain covers the
// half-declared config: a personaProviders entry alone says nothing about which
// model to send, so switching protocol would push the group's default model name
// to another vendor's endpoint. The default chain is kept and the reason logged.
// TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain 覆盖
// 半声明的配置:只有一条 personaProviders 条目并不能说明该发
// 哪个模型,所以切换协议会把组的默认模型名推到
// 另一厂商的端点。此时保留默认链并记录原因。
func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
@@ -200,9 +200,9 @@ func TestRuntimeLLM_PersonaProviderWithoutOwnModelKeepsDefaultChain(t *testing.T
}
}
// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain covers a declared
// protocol that models.json has no group for: the persona keeps the default chain
// instead of failing the turn, and the skip is reported.
// TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain 覆盖已声明
// 但 models.json 没有对应组的协议:该 persona 保留默认链
// 而不是让回合失败,并上报这次跳过。
func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T) {
t.Parallel()
openaiCh := make(chan capturedRequest, 1)
@@ -231,9 +231,9 @@ func TestRuntimeLLM_PersonaProviderWithoutAGroupKeepsDefaultChain(t *testing.T)
}
}
// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain asserts a persona
// naming the active group's own protocol builds no second chain: the default one
// already speaks it, and the per-persona model name reaches it via the router.
// TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain 断言声明了
// 生效组自身协议的 persona 不会构建第二条链:默认链
// 本就说那个协议,按 persona 的模型名经路由器到达它。
func TestRuntimeLLM_PersonaProviderSameProtocolReusesDefaultChain(t *testing.T) {
t.Parallel()
f := newPersonaProviderFixture(t, func(g *runtimeconfig.Group) {
+14 -14
View File
@@ -6,9 +6,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role
// routing is the persona a turn answers as, and a persona without a binding keeps
// the message-role behavior.
// TestRouter_RouteRequestForPersona 钉住裁决 03.4.1:按角色路由里的
// 「角色」是本回合作答所依据的 persona,而未绑定的 persona 保持
// 消息角色行为。
func TestRouter_RouteRequestForPersona(t *testing.T) {
t.Parallel()
r := NewRouter("default",
@@ -39,10 +39,10 @@ func TestRouter_RouteRequestForPersona(t *testing.T) {
}
}
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a
// blank persona/model cannot create a binding, and a persona-routed image request
// is not silently re-routed to a vision model (that decision belongs to
// vision.Router, 裁决 14.4.1/14.4.5).
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch 钉住两个边界:空
// persona/模型无法创建绑定,而按 persona 路由的图像请求
// 不会被静默改道到视觉模型(那个决策属于
// vision.Router,裁决 14.4.1/14.4.5)。
func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
t.Parallel()
r := NewRouter("default",
@@ -63,9 +63,9 @@ func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
}
}
// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona
// binding written on the models.json group reaches the router through the hot
// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。
// TestRuntimeLLM_PersonaRoutesFromModelsJSON 钉住载体:写在
// models.json 组上的按 persona 绑定经热
// 快照到达路由器,且一次重载会拾取改过的绑定(裁决 03.4.1 载体 = models.json)。
func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{{
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
@@ -96,10 +96,10 @@ func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
}
}
// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer
// carries the env vision model: a configured LLM_VISION_MODEL must not switch an
// image-bearing request to another model, because image routing is owned by
// vision.Router (裁决 14.4.1/14.4.5).
// TestRuntimeLLM_NoVisionPreemptionOnImage 钉住运行时快照不再
// 携带 env 视觉模型:已配置的 LLM_VISION_MODEL 不得把
// 携带图片的请求切到另一个模型,因为图像路由归
// vision.Router 所有(裁决 14.4.1/14.4.5)。
func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{{
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
+59 -59
View File
@@ -8,32 +8,32 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// discardLogger returns a logger that drops every record but still carries the
// adapter's module source, so an adapter built without an injected logger can
// never emit an unsourced record.
// discardLogger 返回一个丢弃所有记录的日志器,但仍带有
// 适配器的模块来源,因此未注入日志器而构建的适配器
// 绝不会产生无来源记录。
func discardLogger(source string) *slog.Logger {
return logger.WithSource(slog.New(slog.NewTextHandler(io.Discard, nil)), source)
}
// Reasoning wire values shared by the OpenAI-compatible and Anthropic adapters
// (design/04 §3.2, checked against the provider documents on 2026-10-03).
// OpenAI 兼容与 Anthropic 适配器共用的推理线取值
// (design/04 §3.2,已于 2026-10-03 对照各提供商文档核对)。
const (
// thinkingTypeEnabled and thinkingTypeDisabled are the values of the
// {"thinking":{"type":...}} switch. DeepSeek's default is enabled, so omitting
// the field leaves thinking ON — "do not intervene" is not a way to switch it
// off; only an explicit disabled (or reasoning_effort "none") is.
// thinkingTypeEnabled 与 thinkingTypeDisabled 是
// {"thinking":{"type":...}} 开关的取值。DeepSeek 默认开启,所以省略
// 该字段会让思考保持开启——「不干预」不是关闭它的办法;
// 只有显式 disabled(或 reasoning_effort "none")才是。
thinkingTypeEnabled = "enabled"
thinkingTypeDisabled = "disabled"
// effortNone is the reasoning_effort value that switches thinking off. It is a
// switch, not a strength tier, so it must never be replaced by a tier: mapping
// it onto "low" would invert the caller's intent (thinking off → thinking on).
// effortNone 是关闭思考的 reasoning_effort 取值。它是
// 开关而非强度档位,所以绝不能被替换成某个档位:把它
// 映射到 "low" 会颠倒调用方的意图(关闭思考 → 开启思考)。
effortNone = "none"
)
// reasoningVendor identifies the vendor behind an OpenAI-compatible endpoint.
// The wire protocol is the same for all of them, but the accepted
// reasoning_effort values are not, so a value must be checked against the right
// whitelist instead of being forwarded blindly.
// reasoningVendor 标识 OpenAI 兼容端点背后的厂商。
// 它们的线协议相同,但可接受的
// reasoning_effort 取值不同,所以必须按正确的
// 白名单校验,而不是盲目转发。
type reasoningVendor string
const (
@@ -43,10 +43,10 @@ const (
vendorUnknown reasoningVendor = "unknown"
)
// reasoningEffortAliases is DeepSeek's documented compatibility mapping: the
// service accepts these aliases and applies the mapped tier. The mapping is
// applied on our side too, so the request body always carries the tier the
// provider documents as the effective one (design/04 §3.2 fact 1).
// reasoningEffortAliases 是 DeepSeek 记录的兼容映射:
// 服务接受这些别名并采用映射后的档位。我们在自己这一侧
// 也做同一映射,因此请求体总是携带提供商
// 记录为有效值的那一档(design/04 §3.2 事实 1)。
var reasoningEffortAliases = map[string]string{
"minimal": "low",
"medium": "high",
@@ -54,20 +54,20 @@ var reasoningEffortAliases = map[string]string{
"ultra": "max",
}
// reasoningEffortAllowed is the per-vendor set of accepted reasoning_effort
// values. A vendor absent from this table has no reasoning_effort support at all,
// which is reported rather than guessed.
// reasoningEffortAllowed 是按厂商划分的可接受 reasoning_effort
// 取值集合。不在本表里的厂商完全没有 reasoning_effort 支持,
// 这一点会被上报,而不是靠猜。
//
// There is deliberately no "round down to the nearest tier" fallback: an
// unsupported value is dropped and reported instead, because silently changing it
// can invert the caller's intent — the original bug mapped DeepSeek's "none"
// (thinking off) onto "low" (thinking on) exactly that way.
// 这里刻意没有「向下取到最近档位」的降级:不支持的取值
// 会被丢弃并上报,因为静默改写它可能会颠倒调用方的
// 意图——早先的 bug 正是这样把 DeepSeek 的 "none"(关闭思考)
// 映射到了 "low"(开启思考)。
var reasoningEffortAllowed = map[reasoningVendor]map[string]bool{
vendorOpenAI: tierSet("none", "minimal", "low", "medium", "high", "xhigh", "max"),
vendorDeepSeek: tierSet("none", "low", "high", "max"),
}
// tierSet builds a membership set from the given tiers.
// tierSet 由给定档位构建一个成员集合。
func tierSet(tiers ...string) map[string]bool {
set := make(map[string]bool, len(tiers))
for _, t := range tiers {
@@ -76,12 +76,12 @@ func tierSet(tiers ...string) map[string]bool {
return set
}
// reasoningVendorFor classifies the vendor of an OpenAI-compatible request from
// its model name, falling back to the base URL when the name does not identify
// one. An unrecognized pair yields vendorUnknown so the caller reports "not
// supported" instead of assuming OpenAI semantics. The Anthropic adapter uses the
// same classification to tell a DeepSeek /anthropic endpoint (which ignores
// budget_tokens) from the Anthropic endpoint.
// reasoningVendorFor 按模型名分类 OpenAI 兼容请求的厂商,
// 模型名无法识别出厂商时退回到 base URL。
// 无法识别的组合得到 vendorUnknown,好让调用方上报「不
// 支持」而不是假定 OpenAI 语义。Anthropic 适配器用
// 同一套分类来区分 DeepSeek 的 /anthropic 端点(它忽略
// budget_tokens)与 Anthropic 端点。
func reasoningVendorFor(model, baseURL string) reasoningVendor {
m := strings.ToLower(strings.TrimSpace(model))
switch {
@@ -105,7 +105,7 @@ func reasoningVendorFor(model, baseURL string) reasoningVendor {
return vendorUnknown
}
// thinkingSwitchValue maps an explicit on/off switch onto its wire value.
// thinkingSwitchValue 把显式开/关开关映射到它的线取值。
func thinkingSwitchValue(enable bool) string {
if enable {
return thinkingTypeEnabled
@@ -113,10 +113,10 @@ func thinkingSwitchValue(enable bool) string {
return thinkingTypeDisabled
}
// normalizeDeepSeekEffort maps an effort onto the tier DeepSeek applies and
// reports whether the value is one the service documents: the documented set
// (none/low/high/max) passes through, the compatibility aliases map to their
// official tier.
// normalizeDeepSeekEffort 把 effort 映射到 DeepSeek 实际采用的档位,
// 并报告该值是否为服务记录的取值:记录在案的集合
// (none/low/high/max)原样通过,兼容别名映射到其
// 官方档位。
func normalizeDeepSeekEffort(effort string) (string, bool) {
if tier, ok := reasoningEffortAliases[effort]; ok {
return tier, true
@@ -127,16 +127,16 @@ func normalizeDeepSeekEffort(effort string) (string, bool) {
return "", false
}
// resolveReasoningEffort validates effort against the vendor's whitelist and
// returns the value to send; "" drops the field. An unrecognized value is never
// turned into a different tier — it is dropped and reported with provider, field,
// value and model, because changing it can invert the caller's intent. A DeepSeek
// compatibility alias is normalized to the tier the service applies and reported.
// resolveReasoningEffort 针对厂商白名单校验 effort 并
// 返回要发送的值;"" 表示丢弃该字段。无法识别的值绝不会
// 被换成别的档位——它会被丢弃,并把 provider、field、
// value 与 model 一并上报,因为改动它会颠倒调用方的意图。DeepSeek
// 的兼容别名会被归一化为服务采用的档位并上报。
//
// vendor must be a vendor with a reasoning_effort whitelist (DeepSeek / OpenAI):
// the shapes that carry no such field (vLLM / DashScope) and the endpoints whose
// shape is unknown drop the effort themselves and report it, so their callers
// never reach this function (see reasoning_shape.go, 裁决 03.4.2).
// vendor 必须是带 reasoning_effort 白名单的厂商(DeepSeek / OpenAI):
// 不带该字段的形状(vLLM / DashScope)以及形状未知的
// 端点会自行丢弃 effort 并上报,因此它们的调用方
// 永远不会走到本函数(见 reasoning_shape.go,裁决 03.4.2)。
func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, model string) string {
effort = strings.TrimSpace(effort)
if effort == "" {
@@ -159,8 +159,8 @@ func resolveReasoningEffort(log *slog.Logger, vendor reasoningVendor, effort, mo
return ""
}
// toolChoiceForces reports whether a tool_choice directive obliges the model to
// call a tool ("required", Anthropic's "any", or a named tool). auto/none do not.
// toolChoiceForces 报告 tool_choice 指令是否强制模型
// 调用工具("required"、Anthropic 的 "any",或具名工具)。auto/none 不会。
func toolChoiceForces(toolChoice any) bool {
switch v := toolChoice.(type) {
case nil:
@@ -183,7 +183,7 @@ func toolChoiceForces(toolChoice any) bool {
return false
}
// forcedToolChoiceType reports whether a tool_choice "type" value forces a call.
// forcedToolChoiceType 报告 tool_choice 的 "type" 取值是否强制调用。
func forcedToolChoiceType(value any) bool {
t, _ := value.(string)
switch strings.ToLower(strings.TrimSpace(t)) {
@@ -193,14 +193,14 @@ func forcedToolChoiceType(value any) bool {
return false
}
// reportForcedToolChoiceWithThinking warns about the DeepSeek red line that a
// forced tool call — tool_choice "required"/"any"/a named tool — and thinking are
// not compatible: while thinking is on (its default) the API rejects the forcing
// directive with a 400, so the caller must switch thinking off explicitly first
// (design/04 §3.2 fact 5). It only reports: resolution must never switch thinking
// off on its own. On the OpenAI-compatible adapter the directive is not on the
// wire yet (M2), so the warning is prospective there — it still records that the
// requested combination can never be honoured.
// reportForcedToolChoiceWithThinking 就 DeepSeek 红线报警:
// 强制型工具调用——tool_choice "required"/"any"/具名工具——与思考
// 不兼容:思考开启(其默认)时 API 会用 400 拒绝该强制
// 指令,所以调用方必须先把思考显式关掉
// (design/04 §3.2 事实 5)。它只上报:解算过程绝不能自行
// 关掉思考。在 OpenAI 兼容适配器上该指令尚未上线
// (M2),所以那里只是前瞻性警告——它仍然记录了
// 所请求的组合永远无法被满足。
func reportForcedToolChoiceWithThinking(log *slog.Logger, provider, model string, toolChoice any, thinkingOn bool) {
if !thinkingOn || !toolChoiceForces(toolChoice) {
return
+12 -12
View File
@@ -110,8 +110,8 @@ func TestOpenAIChat_DeclaredShapeSendsItsOwnSwitch(t *testing.T) {
}
}
// TestOpenAIChat_DeclaredShapeEnablesThinking pins the on direction as well, so the
// switch cannot be hard-wired off by a refactor.
// TestOpenAIChat_DeclaredShapeEnablesThinking 也钉住「开启」方向,
// 以免开关被一次重构硬接成关闭。
func TestOpenAIChat_DeclaredShapeEnablesThinking(t *testing.T) {
t.Parallel()
for _, tc := range declaredShapeCases {
@@ -226,9 +226,9 @@ func TestOpenAIChat_ReasoningKindOpenAIKeepsOpenAITiers(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch asserts the effort-only
// shape does not silently swallow a boolean thinking switch: it reports it, and
// points at the documented way to disable reasoning.
// TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch 断言「仅 effort」
// 形状不会静默吞掉布尔 thinking 开关:它会报告该情况,并
// 指出文档记录在案的关闭推理的做法。
func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -256,10 +256,10 @@ func TestOpenAIChat_ReasoningKindOpenAIReportsBooleanSwitch(t *testing.T) {
}
}
// TestOpenAIChat_UnknownReasoningKindSendsNothing asserts a shape this build does
// not implement is reported and sends nothing — even when the model name would
// otherwise look like a vendor we do understand. Guessing here is exactly the
// "fake adaptation" the ruling forbids.
// TestOpenAIChat_UnknownReasoningKindSendsNothing 断言本次构建
// 未实现的形状会被上报、且什么都不发——即便模型名
// 看上去像我们确实理解的厂商也一样。在这里靠猜正是
// 裁决所禁止的「假适配」。
func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -288,9 +288,9 @@ func TestOpenAIChat_UnknownReasoningKindSendsNothing(t *testing.T) {
}
}
// TestOpenAIChat_AutoQwenShapeStaysConservative pins the un-declared default: a
// Qwen endpoint may be vLLM or DashScope, whose field shapes differ, so auto mode
// sends nothing and asks for an explicit declaration instead of guessing one.
// TestOpenAIChat_AutoQwenShapeStaysConservative 钉住未声明的默认:
// Qwen 端点可能是 vLLM 或 DashScope,两者字段形状不同,所以 auto 模式
// 什么都不发,并改为要求显式声明,而不是替它猜一个。
func TestOpenAIChat_AutoQwenShapeStaysConservative(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
+36 -36
View File
@@ -11,20 +11,20 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// captureLog returns a logger writing text records into a buffer, plus the
// buffer, so a test can assert the WARN an adapter emits.
// captureLog 返回一个把文本记录写进缓冲的日志器,外加那个
// 缓冲,好让测试能断言适配器发出的 WARN。
func captureLog() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
return slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
}
// boolPtr is a convenience for the tri-state Thinking.Enable.
// boolPtr 是三态 Thinking.Enable 的便利构造。
func boolPtr(v bool) *bool { return &v }
// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer is the design/04 §3.2
// red line: a budget large enough to eat max_tokens must not be sent as-is —
// the adapter raises max_tokens so thinking and the visible answer both fit, and
// it logs the adjustment (never a silent rewrite).
// TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer 就是 design/04 §3.2
// 的红线:大到能吃光 max_tokens 的预算不得照原样发送——
// 适配器会抬高 max_tokens,让思考与可见答案都放得下,
// 并记录这次调整(绝不是一次静默改写)。
func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -35,8 +35,8 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
WithAnthropicLogger(log))
const budget = 4096
// max_tokens equals the budget: without the guard the whole budget would be
// consumed by thinking and the answer would come back empty.
// max_tokens 恰好等于预算:没有这道守护,整个预算会被
// 思考吃光,答案将空着返回。
if _, err := ad.Chat(context.Background(), Request{
Model: "claude-test",
Messages: []Message{TextMessage(RoleUser, "hi")},
@@ -69,9 +69,9 @@ func TestAnthropicChat_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged pins the non-saturated
// case: when the caller's max_tokens already leaves room, it is sent untouched
// and the budget stays strictly below it.
// TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged 钉住未饱和
// 的情形:调用方的 max_tokens 本已留出空间时,它原样发送,
// 而预算严格低于它。
func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -104,8 +104,8 @@ func TestAnthropicChat_ThinkingBudgetBelowMaxTokensUnchanged(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingDisabledOmitsField asserts the explicit off switch
// sends no thinking directive at all (Anthropic treats a missing field as off).
// TestAnthropicChat_ThinkingDisabledOmitsField 断言显式关闭开关
// 完全不发送 thinking 指令(Anthropic 把字段缺失视为关闭)。
func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -124,8 +124,8 @@ func TestAnthropicChat_ThinkingDisabledOmitsField(t *testing.T) {
}
}
// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum asserts a budget below
// the API minimum is raised (sending it as-is would 400) and reported.
// TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum 断言低于
// API 最小值的预算会被抬高(照发会 400)并上报。
func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -151,8 +151,8 @@ func TestAnthropicChat_ThinkingBudgetRaisedToProtocolMinimum(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortPassthrough asserts a supported tier on a known
// provider is forwarded verbatim, with no warning.
// TestOpenAIChat_ReasoningEffortPassthrough 断言已知提供商上的
// 受支持档位被原样转发,且不发出警告。
func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -175,10 +175,10 @@ func TestOpenAIChat_ReasoningEffortPassthrough(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek asserts a DeepSeek
// compatibility alias is normalized to the tier the service applies (medium →
// high per the official mapping table) and reported, never graded down to a
// weaker tier of our own choosing.
// TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek 断言 DeepSeek 的
// 兼容别名被归一化为服务采用的档位(按官方映射表 medium →
// high)并上报,绝不按我们自己挑的
// 更弱档位降级。
func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -204,9 +204,9 @@ func TestOpenAIChat_ReasoningEffortAliasMappedForDeepSeek(t *testing.T) {
}
}
// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider asserts an
// unrecognized provider/model leaves the request untouched and reports the
// ignored field (design/04 §3.2: never silently ineffective).
// TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider 断言无法识别的
// 提供商/模型会让请求保持不动,并上报被
// 忽略的字段(design/04 §3.2:绝不静默失效)。
func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -233,10 +233,10 @@ func TestOpenAIChat_ReasoningEffortDroppedForUnknownProvider(t *testing.T) {
}
}
// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded documents the deliberate
// choice for the Qwen3 hard switch: the OpenAI-compatible protocol has no
// portable wire shape for enable_thinking, so this version reports it and leaves
// the request byte-identical instead of guessing a shape that could 400.
// TestOpenAIChat_QwenEnableThinkingReportedNotForwarded 记录了针对 Qwen3
// 硬开关的刻意选择:OpenAI 兼容协议没有 enable_thinking 的
// 可移植线格式,所以本版本上报它,并让请求
// 逐字节保持原样,而不是去猜一个可能导致 400 的形状。
func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -277,9 +277,9 @@ func TestOpenAIChat_QwenEnableThinkingReportedNotForwarded(t *testing.T) {
}
}
// TestOpenAIChat_ThinkingBudgetReportedIgnored asserts a token budget (an
// Anthropic-style directive) is reported as unmappable on an OpenAI-compatible
// endpoint rather than silently dropped.
// TestOpenAIChat_ThinkingBudgetReportedIgnored 断言 token 预算
// (Anthropic 风格的指令)在 OpenAI 兼容端点上会被上报为
// 无法映射,而不是被静默丢弃。
func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
@@ -305,8 +305,8 @@ func TestOpenAIChat_ThinkingBudgetReportedIgnored(t *testing.T) {
}
}
// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer runs the same
// red-line check on the streaming path, which builds its own request body.
// TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer 在流式
// 路径上跑同一条红线检查,该路径会自建请求体。
func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T) {
t.Parallel()
ch := make(chan anthropicCaptured, 1)
@@ -337,8 +337,8 @@ func TestAnthropicChatStream_ThinkingBudgetLeavesRoomForTheAnswer(t *testing.T)
}
}
// TestReasoningVendorFor_Classification locks the vendor detection the whitelist
// depends on, including the fallback to the base URL.
// TestReasoningVendorFor_Classification 锁住白名单所依赖的厂商
// 检测,包括回退到 base URL 的那条路径。
func TestReasoningVendorFor_Classification(t *testing.T) {
t.Parallel()
+66 -66
View File
@@ -2,22 +2,22 @@ package model
import "strings"
// Router maps roles to model names. An unknown role resolves to the configured
// default model. A Router also carries a provider per role (defaulting to the
// global provider) so a multi-vendor deployment can pair a role's model with the
// correct protocol; the provider selection is surfaced via RouteProvider.
// Router 把角色映射到模型名。未知角色解析为已配置的
// 默认模型。Router 还按角色携带一个提供商(默认用
// 全局提供商),好让多厂商部署能把角色的模型与
// 正确协议配对;提供商选择通过 RouteProvider 暴露。
//
// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn
// answers as, not the LLM message role: a persona binding wins over the
// message-role binding, and a persona without a binding keeps the previous
// behavior. The binding carrier is the active models.json group
// (runtimeconfig.Group.PersonaModels / PersonaProviders).
// 按裁决 03.4.1,按角色路由里的「角色」是本回合以之
// 作答的 **persona**,而不是 LLM 消息角色:persona 绑定优先于
// 消息角色绑定,而未绑定的 persona 保持原有
// 行为。绑定的载体是生效的 models.json 组
// (runtimeconfig.Group.PersonaModels / PersonaProviders)。
//
// Image routing is deliberately NOT this router's job any more (裁决
// 14.4.1/14.4.5): the single authority for layered image routing is
// runtime/multimodal/vision.Router, which the loop calls with the main model's
// real image-input capability. A "has image → switch to the vision model" rule
// here used to bypass that decision.
// 图像路由刻意不再是本 Router 的职责(裁决
// 14.4.1/14.4.5):分层图像路由的唯一权威是
// runtime/multimodal/vision.Router,由 loop 带着主模型真实的
// 图像输入能力去调用它。这里曾有的「带图 → 切到视觉模型」规则
// 绕过了那个决策。
type Router struct {
defaultModel string
byRole map[Role]string
@@ -28,25 +28,25 @@ type Router struct {
personaProvider map[string]string
}
// RouterOption configures a Router.
// RouterOption 用于配置 Router。
type RouterOption func(*Router)
// WithRoleModel binds a model to a role.
// WithRoleModel 把模型绑定到角色。
func WithRoleModel(role Role, model string) RouterOption {
return func(r *Router) { r.byRole[role] = model }
}
// WithVisionModel configures the dedicated vision model. It is kept for
// backwards compatibility (tests and callers that route a pure image request by
// model name) but production no longer sets it: image routing belongs to
// vision.Router (裁决 14.4.1/14.4.5).
// WithVisionModel 配置专用视觉模型。它保留下来是为了
// 向后兼容(测试与按模型名路由纯图像请求的调用方),
// 但生产路径不再设置它:图像路由属于
// vision.Router(裁决 14.4.1/14.4.5)。
func WithVisionModel(model string) RouterOption {
return func(r *Router) { r.visionModel = model }
}
// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or
// model is ignored so a partially configured group cannot create a binding that
// routes a persona to an empty model.
// WithPersonaModel 把模型绑定到 persona(裁决 03.4.1)。空的 persona 或
// 模型会被忽略,因此半配置的组无法造出一个把 persona 路由到
// 空模型的绑定。
func WithPersonaModel(persona, model string) RouterOption {
return func(r *Router) {
if p := trimKey(persona); p != "" && model != "" {
@@ -55,9 +55,9 @@ func WithPersonaModel(persona, model string) RouterOption {
}
}
// WithPersonaProvider binds a vendor protocol to a persona, so one persona can
// answer through a different vendor than the group default (裁决 03.4.1). Pair it
// with WithPersonaModel.
// WithPersonaProvider 把厂商协议绑定到 persona,这样一个 persona 可以
// 走与组默认不同的厂商作答(裁决 03.4.1)。请与
// WithPersonaModel 配对使用。
func WithPersonaProvider(persona, provider string) RouterOption {
return func(r *Router) {
if p := trimKey(persona); p != "" && provider != "" {
@@ -66,21 +66,21 @@ func WithPersonaProvider(persona, provider string) RouterOption {
}
}
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
// no explicit binding. It defaults to openai.
// WithDefaultProvider 设置 RouteProvider 对没有显式绑定的角色
// 返回的提供商。它默认是 openai。
func WithDefaultProvider(provider string) RouterOption {
return func(r *Router) { r.defaultProvider = provider }
}
// WithRoleProvider binds a provider to a role, so a multi-vendor deployment can
// route a role to a different vendor's protocol (e.g. one role on anthropic, one
// on openai). Pair it with WithRoleModel for the role's model name.
// WithRoleProvider 把提供商绑定到角色,好让多厂商部署
// 能把角色路由到另一厂商的协议(例如一个角色走 anthropic、一个
// 走 openai)。请与 WithRoleModel 配对以给出该角色的模型名。
func WithRoleProvider(role Role, provider string) RouterOption {
return func(r *Router) { r.providerByRole[role] = provider }
}
// NewRouter builds a Router with a default model applied to any role that has
// no explicit binding.
// NewRouter 构建 Router,并对任何没有显式绑定的角色
// 套用默认模型。
func NewRouter(defaultModel string, opts ...RouterOption) *Router {
r := &Router{
defaultModel: defaultModel,
@@ -96,11 +96,11 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
return r
}
// trimKey normalizes a persona id or provider name before it is stored, so
// " cyrene" and "cyrene" cannot become two different bindings.
// trimKey 在存入之前归一化 persona id 或提供商名,因此
// " cyrene" 与 "cyrene" 不会变成两个不同的绑定。
func trimKey(k string) string { return strings.TrimSpace(k) }
// Route returns the model for a role, falling back to the default model.
// Route 返回角色的模型,回退到默认模型。
func (r *Router) Route(role Role) string {
if m, ok := r.byRole[role]; ok && m != "" {
return m
@@ -108,11 +108,11 @@ func (r *Router) Route(role Role) string {
return r.defaultModel
}
// RouteProvider returns the provider for a role, falling back to the router's
// default provider. It is the routing hook for a multi-vendor deployment: each
// role can carry its own provider+model pairing, and roles without a binding fall
// back to the global provider. The runtime/loop is expected to build a per-role
// adapter or chain from this decision (the provider-aware factory is the builder).
// RouteProvider 返回角色的提供商,回退到 Router 的
// 默认提供商。它是多厂商部署的路由钩子:每个
// 角色可以携带自己的提供商+模型配对,而未绑定的角色回
// 退到全局提供商。runtime/loop 应据该决策构建按角色的
// 适配器或链(提供商感知工厂就是构建器)。
func (r *Router) RouteProvider(role Role) string {
if p, ok := r.providerByRole[role]; ok && p != "" {
return p
@@ -120,16 +120,16 @@ func (r *Router) RouteProvider(role Role) string {
return r.defaultProvider
}
// RouteRequest picks the model for a request and reports whether the request
// carries image content. When the request carries image content and a vision
// model is configured, the vision model is returned with needsVision set to
// true. Otherwise the role of the last message drives routing (user by
// default).
// RouteRequest 为请求挑出模型,并报告该请求
// 是否携带图像内容。当请求携带图像内容且已配置
// 视觉模型时,返回视觉模型并把 needsVision 置为
// true。否则由最后一条消息的角色驱动路由(默认
// user)。
//
// Deprecated in the production path: the loop routes by persona via
// RouteRequestForPersona, and image routing belongs to vision.Router (裁决
// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the
// agent package and the existing tests are built on.
// 在生产路径中已弃用:loop 改经
// RouteRequestForPersona 按 persona 路由,图像路由属于 vision.Router(裁决
// 14.4.1/14.4.5)。本方法保留是因为 agent 包与
// 既有测试都建立在 ModelRouter 契约之上。
func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
role := RoleUser
if n := len(req.Messages); n > 0 {
@@ -142,18 +142,18 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
return r.Route(role), needsVision
}
// PersonaRouter is the optional persona-aware routing extension of ModelRouter
// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over
// RouteRequest, so a per-persona model/provider binding actually reaches the
// request. It is a separate interface so a router that only knows message roles
// keeps working unchanged.
// PersonaRouter 是 ModelRouter 可选的 persona 感知路由扩展
// (裁决 03.4.1)。loop 会在 agent 的 router 上探测它,并优先于
// RouteRequest 使用,从而让按 persona 的模型/提供商绑定真正到达
// 请求。它单列为一个接口,好让只知道消息角色的 router
// 保持可用、不被改动。
type PersonaRouter interface {
RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool)
}
// RouteForPersona returns the model bound to persona, falling back to the
// router's default model. Unlike Route (message role) this is the persona-level
// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona.
// RouteForPersona 返回绑定到 persona 的模型,回退到
// Router 的默认模型。与 Route(消息角色)不同,这是 persona 级
// 绑定:裁决 03.4.1 把按角色路由的「角色」定义为 persona。
func (r *Router) RouteForPersona(persona string) string {
if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" {
return m
@@ -161,8 +161,8 @@ func (r *Router) RouteForPersona(persona string) string {
return r.defaultModel
}
// RouteProviderForPersona returns the provider bound to persona, falling back to
// the router's default provider (itself the active group's protocol).
// RouteProviderForPersona 返回绑定到 persona 的提供商,回退到
// Router 的默认提供商(其本身即生效组的协议)。
func (r *Router) RouteProviderForPersona(persona string) string {
if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" {
return p
@@ -170,11 +170,11 @@ func (r *Router) RouteProviderForPersona(persona string) string {
return r.defaultProvider
}
// RouteRequestForPersona routes a request as the given persona: a persona
// binding wins over the message-role decision, and a persona without a binding
// falls back to RouteRequest so nothing changes for the unconfigured case. It
// reports whether the request carries an image, but never switches the model to
// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5).
// RouteRequestForPersona 以给定 persona 路由请求:persona
// 绑定优先于消息角色决策,而没有绑定的 persona
// 回退到 RouteRequest,因此未配置的情形毫无变化。它
// 报告请求是否携带图片,但绝不会把模型切到
// 视觉模型——那个决策属于 vision.Router(裁决 14.4.1/14.4.5)。
func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) {
needsVision = HasImage(req.Messages)
if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" {
@@ -187,7 +187,7 @@ func (r *Router) RouteRequestForPersona(personaID string, req Request) (model st
return r.Route(role), needsVision
}
// HasImage reports whether any message carries an image content part.
// HasImage 报告是否有任何消息携带图片内容片段。
func HasImage(msgs []Message) bool {
for _, m := range msgs {
for _, part := range m.Content {
+95 -95
View File
@@ -12,45 +12,45 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// ModelRouter is the routing contract the agent (and loop) depend on. *Router
// implements it; RuntimeLLM implements it too, so a hot-swappable provider can
// serve as both the adapter and the router without a separate router reference
// to keep in sync.
// ModelRouter 是 agent(与 loop)依赖的路由契约。*Router
// 实现它;RuntimeLLM 也实现它,因此一个可热切换的提供商
// 可以同时充当适配器与路由器,无需另存一份 router 引用
// 去保持同步。
type ModelRouter interface {
RouteRequest(req Request) (model string, needsVision bool)
}
// runtimeChainSnapshot is an immutable, self-contained LLM stack snapshot. It is
// stored and swapped atomically: a request that already captured a snapshot
// keeps using that exact chain+router for its whole call, so a reload never
// interrupts an in-flight request.
// runtimeChainSnapshot 是不可变、自包含的 LLM 栈快照。它
// 被原子存储与换入:已经捕获某个快照的请求
// 整次调用都用那条确切的链+路由器,所以一次重载绝不会
// 打断进行中的请求。
type runtimeChainSnapshot struct {
chain *Chain
router *Router
group runtimeconfig.Group
groups []runtimeconfig.Group
// providerChains holds one chain per *other* protocol declared by the active
// group's personaProviders (裁决 03.4.1 的 per-persona provider:跨协议换
// adapter). A persona whose provider binding resolves to one of these keys is
// served by that chain; every other request keeps the default chain above.
// providerChains 为生效组的 personaProviders 声明的每个
// *其它* 协议各存一条链(裁决 03.4.1 的 per-persona provider:跨协议换
// adapter)。提供商绑定解析到这些键之一的 persona 由
// 那条链服务;其余所有请求仍走上面的默认链。
providerChains map[string]*Chain
}
// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It
// implements Adapter (delegating to the current chain) and ModelRouter
// (delegating to the current router). A panel save reloads models.json, rebuilds
// a fresh chain+router and atomically swaps it in: new calls use the new stack
// while calls already in flight finish on the old one.
// RuntimeLLM 是运行时的可热切换 LLM 提供商栈。它
// 实现 Adapter(委托给当前链)与 ModelRouter
// (委托给当前路由器)。面板保存会重载 models.json、重建
// 一份新的链+路由器并原子换入:新调用使用新栈,
// 而进行中的调用在旧栈上跑完。
type RuntimeLLM struct {
path string
env config.LLMConfig
log *slog.Logger
current atomic.Value // *runtimeChainSnapshot
current atomic.Value // 类型:*runtimeChainSnapshot
}
// NewRuntimeLLM builds the provider stack from models.json at path, falling back
// to env. It loads the initial snapshot so the runtime is usable immediately.
// log may be nil (a discard logger is used).
// NewRuntimeLLM 由 path 处的 models.json 构建提供商栈,并回退
// 到 env。它会加载初始快照,因此运行时立即可用。
// log 可为 nil(此时使用丢弃式日志器)。
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
@@ -60,11 +60,11 @@ func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *Runtime
return m
}
// Path returns the models.json path this manager reads and reloads from.
// Path 返回本管理器读取并重载的 models.json 路径。
func (m *RuntimeLLM) Path() string { return m.path }
// Groups returns a copy of the current loaded groups; the active group (the one
// a new request uses by default) is index 0.
// Groups 返回当前已加载组的一份拷贝;生效组(新请求
// 默认使用的那个)是下标 0。
func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
src := m.load().groups
out := make([]runtimeconfig.Group, len(src))
@@ -72,18 +72,18 @@ func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
return out
}
// Group returns the active group (index 0).
// Group 返回生效组(下标 0)。
func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group }
// Current returns the current chain and router. The returned chain stays valid
// for calls already in flight; new calls pick up the latest swap.
// Current 返回当前链与路由器。返回的链对进行中的
// 调用仍然有效;新调用会拿到最新的换入结果。
func (m *RuntimeLLM) Current() (*Chain, *Router) {
snap := m.load()
return snap.chain, snap.router
}
// Reload re-reads models.json (falling back to env) and atomically rebuilds the
// in-use chain+router. In-flight calls keep the previous snapshot.
// Reload 重新读取 models.json(回退到 env)并原子重建
// 在用的链+路由器。进行中的调用保持原快照。
func (m *RuntimeLLM) Reload() error {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
@@ -92,19 +92,19 @@ func (m *RuntimeLLM) Reload() error {
return m.swap(groups)
}
// Provider returns the active group's provider protocol.
// Provider 返回生效组的提供商协议。
func (m *RuntimeLLM) Provider() string { return m.load().group.Provider }
// Model returns the active group's primary model name.
// Model 返回生效组的主模型名。
func (m *RuntimeLLM) Model() string { return m.load().group.Model }
// Name implements Adapter, returning the active group id.
// Name 实现 Adapter,返回生效组 id。
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
// Chat implements Adapter, delegating to the chain the request's persona
// resolves to. A request that began before a reload completes on the snapshot it
// captured. When no model is configured (nil chain) it returns ErrNoModel rather
// than panicking.
// Chat 实现 Adapter,委托给请求的 persona 所解析到的
// 链。在重载完成前开始的请求,会在它已捕获的快照上
// 跑完。未配置模型(链为 nil)时返回 ErrNoModel,而不是
// panic。
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
chain := m.chainFor(req.PersonaID)
if chain == nil {
@@ -113,7 +113,7 @@ func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Respo
return chain.Chat(ctx, req, opts)
}
// ChatStream implements Adapter.
// ChatStream 实现 Adapter。
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
chain := m.chainFor(req.PersonaID)
if chain == nil {
@@ -122,20 +122,20 @@ func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(S
return chain.ChatStream(ctx, req, onEvent)
}
// chainFor resolves the chain that serves this persona (裁决 03.4.1 的
// per-persona provider). It consumes Router.RouteProviderForPersona — the
// provider decision the router already made for this persona — and only switches
// when BOTH hold:
// chainFor 解出服务该 persona 的链(裁决 03.4.1 的
// per-persona provider)。它消费 Router.RouteProviderForPersona ——
// 路由器已为该 persona 做出的提供商决策 —— 且只在
// 二者同时成立时才切换:
//
// - the persona has its own model binding (an entry in personaProviders alone
// would send the group's default model name to another vendor's endpoint),
// - and a chain exists for that provider (a declared protocol with no matching
// group in models.json is reported when the snapshot is built, and the
// persona then keeps the default chain rather than failing the turn).
// - 该 persona 有自己的模型绑定(只写 personaProviders 一项
// 会把组的默认模型名发到另一厂商的端点),
// - 且该提供商存在一条链(声明了协议但 models.json 里没有
// 对应组的情况会在构建快照时上报,
// 该 persona 随后保留默认链,而不是让回合失败)。
//
// Everything else — no persona, no binding, same protocol as the active group —
// returns the default chain, so a deployment without per-persona providers
// behaves exactly as before.
// 其余情形 —— 无 persona、无绑定、与生效组同协议 ——
// 返回默认链,因此没有按 persona 提供商的部署
// 行为与之前完全一致。
func (m *RuntimeLLM) chainFor(persona string) *Chain {
snap := m.load()
if p := trimKey(persona); p != "" && snap.router != nil {
@@ -148,7 +148,7 @@ func (m *RuntimeLLM) chainFor(persona string) *Chain {
return snap.chain
}
// RouteRequest implements ModelRouter, delegating to the current router.
// RouteRequest 实现 ModelRouter,委托给当前路由器。
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
r := m.load().router
if r == nil {
@@ -157,9 +157,9 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
return r.RouteRequest(req)
}
// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes
// a turn as the persona it answers as, and a per-persona binding in the active
// models.json group reaches the request through here.
// RouteRequestForPersona 实现 PersonaRouter(裁决 03.4.1):loop 以
// 本回合作答所依据的 persona 来路由,生效 models.json 组里的
// 按 persona 绑定经由这里到达请求。
func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) {
r := m.load().router
if r == nil {
@@ -168,8 +168,8 @@ func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (stri
return r.RouteRequestForPersona(personaID, req)
}
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
// calls report a clear no-model error instead of panicking.
// loadInitial 构建首个快照;构建失败会留下 nil 链,
// 使调用报告清晰的「无模型」错误而不是 panic。
func (m *RuntimeLLM) loadInitial() {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
@@ -178,8 +178,8 @@ func (m *RuntimeLLM) loadInitial() {
_ = m.swap(groups)
}
// swap rebuilds and atomically stores a snapshot for groups. A blank slice falls
// back to the env-synthesized group so there is always at least one.
// swap 为 groups 重建快照并原子存储。空切片会回
// 退到 env 合成的组,因此始终至少有一个组。
func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
if len(groups) == 0 {
groups = runtimeconfig.GroupFromEnv(m.env)
@@ -192,8 +192,8 @@ func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
return nil
}
// load returns the current snapshot, or an empty (nil chain) one when the
// manager was never initialized.
// load 返回当前快照;管理器从未初始化时返回一个空
// (nil 链)快照。
func (m *RuntimeLLM) load() *runtimeChainSnapshot {
if v := m.current.Load(); v != nil {
return v.(*runtimeChainSnapshot)
@@ -201,18 +201,18 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
return &runtimeChainSnapshot{}
}
// buildSnapshot turns the active group (index 0) into a chain+router. A group
// without a base URL/model yields a nil chain (no model configured), surfaced as
// ErrNoModel by the adapter methods.
// buildSnapshot 把生效组(下标 0)变成链+路由器。没有
// base URL/模型的组会产出 nil 链(未配置模型),由适配器
// 方法表现为 ErrNoModel。
//
// The env-local rung is copied onto the group's flat config because a models.json
// group does not carry it: without that copy the third degradation level
// (裁决 03.4.3) would silently disappear as soon as models.json exists.
// env 本地级会被复制到组的扁平配置上,因为 models.json
// 的组不携带它:没有这次复制,第三级降级
// (裁决 03.4.3)只要 models.json 一存在就会静默消失。
//
// The router is seeded with the active group's per-persona bindings
// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing
// is owned by vision.Router, and a "has image → switch model" rule here bypassed
// that decision (裁决 14.4.1/14.4.5).
// 路由器以生效组的按 persona 绑定播种
// (裁决 03.4.1)。它刻意不给 env 的视觉模型:图像路由
// 归 vision.Router 所有,而这里曾有的「带图 → 切换模型」规则绕过了
// 那个决策(裁决 14.4.1/14.4.5)。
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
active := groups[0]
cfg := active.ToLLMConfig()
@@ -222,9 +222,9 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
return nil, err
}
if chain != nil {
// Budgets are resolved inside the chain: the active group's own
// maxTokens (models.json) wins, and env.FallbackMaxTokens only caps the
// last step of a chain that really has a fallback (design/04 §3.1).
// 预算在链内部解出:生效组自己的
// maxTokens(models.json)优先,env.FallbackMaxTokens 只约束
// 确实有降级步骤的链的最后一步(design/04 §3.1)。
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
}
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
@@ -237,17 +237,17 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
}, nil
}
// personaProviderChains builds one chain per protocol that the active group's
// personaProviders declares and that models.json actually provides a group for
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter). The persona's own
// model name is pinned as the chain's primary model, so a caller that stamps the
// persona without a routed model name still reaches the right endpoint.
// personaProviderChains 为生效组 personaProviders 声明的、且
// models.json 确实提供了对应组的每种协议各构建一条链
// (裁决 03.4.1 的 per-persona provider:跨协议换 adapter)。persona 自己的
// 模型名被钉为该链的主模型,因此即便调用方打上的
// persona 没带被路由的模型名,也仍能到达正确端点。
//
// A persona that names the same protocol as the active group needs no chain of
// its own: the default chain already speaks that protocol, and the per-persona
// model name reaches it through the router. A personaProviders entry whose
// protocol has no group is reported and skipped — a half-built chain could only
// fail, and the persona keeps the default chain instead.
// 与生效组同协议的 persona 不需要自己
// 的链:默认链本就说那个协议,而按 persona 的
// 模型名经路由器到达它。personaProviders 中协议
// 没有对应组的条目会被上报并跳过——半构建的链只会
// 失败,该 persona 改为保留默认链。
func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) map[string]*Chain {
if len(active.PersonaProviders) == 0 {
return nil
@@ -283,12 +283,12 @@ func personaProviderChains(groups []runtimeconfig.Group, active runtimeconfig.Gr
return out
}
// personaChain builds and budgets the chain serving one persona through another
// vendor's group: the group's flat config with the persona's own model name as
// the primary model, plus the shared local last rung. The reasoning shape
// (reasoning.kind) travels with the group's config, so a persona routed to
// another vendor keeps that vendor's switch shape. It reports the reason and
// returns nil when the group cannot serve a chain.
// personaChain 构建并通过另一厂商的组服务某个 persona 的链,并
// 为其定预算:组的扁平配置,persona 自己的模型名作为
// 主模型,加上共享的本地最后一级。推理形状
// (reasoning.kind)随组的配置一起走,因此被路由到
// 另一厂商的 persona 仍保持该厂商的开关形状。它会报告原因,
// 并在该组无法服务一条链时返回 nil。
func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log *slog.Logger) *Chain {
cfg := g.ToLLMConfig()
if cfg.Provider == ProviderAnthropic {
@@ -310,10 +310,10 @@ func personaChain(g runtimeconfig.Group, model string, env config.LLMConfig, log
return chain.WithTokenBudget(g.MaxTokens, env.FallbackMaxTokens)
}
// groupForProvider returns the first group speaking the given protocol. The group
// order in models.json is the operator's choice, so "first match" is a stable,
// documentable rule rather than a silent pick — a duplicate protocol is reported
// by the builder when it matters.
// groupForProvider 返回第一条说指定协议的组。models.json 里
// 组的顺序由运维者决定,所以「第一个匹配」是一条稳定、
// 可记录的规则,而不是一次静默挑选——重复协议会在
// 要紧的时候由构建器上报。
func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimeconfig.Group, bool) {
for _, g := range groups {
if g.Provider == provider {
@@ -323,9 +323,9 @@ func groupForProvider(groups []runtimeconfig.Group, provider string) (runtimecon
return runtimeconfig.Group{}, false
}
// personaRouterOptions turns the active group's per-persona model/provider
// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot
// stays inside the 80-line function budget.
// personaRouterOptions 把生效组的按 persona 模型/提供商
// 绑定转换成 router 选项(裁决 03.4.1)。它是一个辅助函数,好让 buildSnapshot
// 留在 80 行函数预算之内。
func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption {
opts := []RouterOption{WithDefaultProvider(cfg.Provider)}
for persona, model := range active.PersonaModels {
+7 -7
View File
@@ -11,7 +11,7 @@ import (
)
func rtEnv() config.LLMConfig {
// Env fallback matches "openai default" (provider not anthropic).
// env 降级符合「openai 默认」(提供商不是 anthropic)。
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
@@ -30,7 +30,7 @@ func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string {
}
func TestRuntimeLLM_EnvFallback(t *testing.T) {
// Missing file → env fallback provider/model.
// 文件缺失 → env 降级的提供商/模型。
m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" {
t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model())
@@ -40,7 +40,7 @@ func TestRuntimeLLM_EnvFallback(t *testing.T) {
t.Fatal("router is nil after env fallback")
}
if chain == nil {
// No real endpoint is fine; the adapter should report ErrNoModel, not panic.
// 没有真实端点没关系;适配器应报 ErrNoModel,而不是 panic。
if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel {
t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err)
}
@@ -68,7 +68,7 @@ func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) {
if m.Model() != "gpt-4o" {
t.Fatalf("before reload Model = %q, want gpt-4o", m.Model())
}
// Rewrite the file with an anthropic provider/model, then reload.
// 用 anthropic 提供商/模型重写该文件,然后重载。
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{
{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"},
}); err != nil {
@@ -129,15 +129,15 @@ func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) {
return
}
if chain == nil {
// A nil chain means the config had no base/model; our test file has one,
// so a swap must always produce a chain. Guard anyway.
// nil 链意味着配置里没有 base/model;我们的测试文件里有,
// 所以一次换入必须总能产出链。仍然加一道守护。
t.Error("chain is nil during concurrent read")
return
}
}
}()
}
// Reload a number of times while the readers spin.
// 在读方自旋期间重载若干次。
for i := 0; i < 200; i++ {
if err := m.Reload(); err != nil {
t.Fatalf("reload %d: %v", i, err)
+68 -68
View File
@@ -1,59 +1,59 @@
package model
// StreamKind enumerates the kinds of structured events produced while a
// completion streams. It mirrors OpenClaw's AssistantMessageEvent segments
// (text / thinking / tool_call) plus a tool-execution progress event, so the
// frontend can render each segment into its own surface (body, collapsed
// thinking block, tool card) rather than accumulating a single opaque token
// stream.
// StreamKind 枚举补全流式过程中产生的结构化事件种类。
// 它对应 OpenClaw 的 AssistantMessageEvent 片段
// (文本 / 思考 / tool_call)外加一个工具执行进度事件,
// 因此前端可以把每个片段渲染到自己的界面元素(正文、折叠
// 思考块、工具卡片),而不是累积成一条不透明的 token
// 流。
type StreamKind string
const (
// StreamKindTextStart opens the body-text segment. The single text segment
// of a response is bracketed by TextStart/TextEnd; deltas fill it.
// StreamKindTextStart 打开正文文本片段。一次响应的单个文本
// 片段由 TextStart/TextEnd 包起来;增量填充其中。
StreamKindTextStart StreamKind = "text_start"
// StreamKindTextDelta is one body-text token fragment.
// StreamKindTextDelta 是一个正文文本 token 片段。
StreamKindTextDelta StreamKind = "text_delta"
// StreamKindTextEnd closes the body-text segment.
// StreamKindTextEnd 关闭正文文本片段。
StreamKindTextEnd StreamKind = "text_end"
// StreamKindThinkingStart opens a chain-of-thought segment (frontend
// collapsed block). Reserved: adapters that report reasoning emit it.
// StreamKindThinkingStart 打开一个思维链片段(前端
// 折叠块)。保留:上报推理的适配器会发出它。
StreamKindThinkingStart StreamKind = "thinking_start"
// StreamKindThinkingDelta is one reasoning token fragment.
// StreamKindThinkingDelta 是一个推理 token 片段。
StreamKindThinkingDelta StreamKind = "thinking_delta"
// StreamKindThinkingEnd closes a reasoning segment.
// StreamKindThinkingEnd 关闭一个推理片段。
StreamKindThinkingEnd StreamKind = "thinking_end"
// StreamKindToolCallStart opens a tool invocation's streaming form.
// StreamKindToolCallStart 打开一次工具调用的流式形态。
StreamKindToolCallStart StreamKind = "tool_call_start"
// StreamKindToolCallDelta is a fragment of the tool arguments (e.g. the
// incremental input_json of an Anthropic tool_use block).
// StreamKindToolCallDelta 是工具参数的一个片段(例如
// Anthropic tool_use 块增量式的 input_json)。
StreamKindToolCallDelta StreamKind = "tool_call_delta"
// StreamKindToolCallEnd closes a tool invocation block.
// StreamKindToolCallEnd 关闭一个工具调用块。
StreamKindToolCallEnd StreamKind = "tool_call_end"
// StreamKindToolExecutionStart opens the execution of a tool the model
// requested (design/04 §1.1: entering execution). It is emitted by the tool
// loop, not the model adapter; Phase carries the running label.
// StreamKindToolExecutionStart 打开模型所请求工具的
// 执行(design/04 §1.1:进入执行)。它由工具循环
// 发出,而不是模型适配器;Phase 携带 running 标签。
StreamKindToolExecutionStart StreamKind = "tool_execution_start"
// StreamKindToolExecutionUpdate reports intermediate progress or partial
// output while a requested tool runs (design/04 §1.1). Multiple frames per
// execution are allowed; the wire mapping is defined, and a producer appears
// once tools can report mid-execution progress.
// StreamKindToolExecutionUpdate 报告所请求工具运行期间的
// 中间进度或部分输出(design/04 §1.1)。每次执行允许
// 多帧;线格式映射已定义,等工具能上报执行中进度时
// 就会有生产者。
StreamKindToolExecutionUpdate StreamKind = "tool_execution_update"
// StreamKindToolExecutionEnd closes a tool execution with its terminal
// phase (done or error). It is emitted by the tool loop after Invoke
// returns.
// StreamKindToolExecutionEnd 以终态阶段(done 或 error)
// 关闭一次工具执行。它由工具循环在 Invoke
// 返回后发出。
StreamKindToolExecutionEnd StreamKind = "tool_execution_end"
// StreamKindTurnStart opens an assistant turn at the loop's run boundary
// (design/04 §1.1 turn.start). It carries no payload: the frame shell's
// session/turn identity brackets the turn on the client.
// StreamKindTurnStart 在 loop 的运行边界打开一个助手回合
// (design/04 §1.1 turn.start)。它不携带负载:帧外壳的
// 会话/回合标识在客户端一侧把该回合包起来。
StreamKindTurnStart StreamKind = "turn_start"
// StreamKindTurnEnd closes an assistant turn at the loop's run boundary,
// however the run settled (final answer, guardrail stop, terminated tool
// loop, exhausted rounds or an error).
// StreamKindTurnEnd 在 loop 的运行边界关闭一个助手回合,
// 无论运行如何收场(最终答复、护栏停止、被终止的工具
// 循环、轮次耗尽或错误)。
StreamKindTurnEnd StreamKind = "turn_end"
)
@@ -69,48 +69,48 @@ const (
ToolPhaseError = "error"
)
// StreamEvent is a single structured event in a streamed completion. It replaces
// the former plain text delta as the unit delivered by Adapter.ChatStream, so a
// consumer can distinguish body text from tool calls (and, in time, reasoning
// segments) and track each by Index/ID.
// StreamEvent 是流式补全里的单个结构化事件。它取代
// 原先的纯文本增量,成为 Adapter.ChatStream 投递的单位,
// 因此消费者能区分正文文本、工具调用(以及将来还有推理
// 片段),并按 Index/ID 跟踪各自。
type StreamEvent struct {
// Kind classifies the event (see StreamKind).
// Kind 对事件分类(见 StreamKind)。
Kind StreamKind
// Delta carries the incremental payload: body text for TextDelta, reasoning
// text for ThinkingDelta, or a partial_argument fragment for ToolCallDelta.
// Tool-execution events never pack their phase or tool name in here —
// those live in the structured Phase/Tool fields.
// Delta 携带增量负载:TextDelta 时为正文文本,ThinkingDelta
// 时为推理文本,ToolCallDelta 时为 partial_argument 片段。
// 工具执行事件绝不把阶段或工具名塞进这里——
// 那些住在结构化的 Phase/Tool 字段里。
Delta string
// Index is the zero-based content-block index the event belongs to, when the
// provider reports one. The frontend uses it to correlate a segment's
// start/delta/end events.
// Index 是事件所属内容块的零基索引(提供商上报时)。
// 前端用它关联一个片段的
// start/delta/end 事件。
Index int
// ID is the provider block/index identifier for a tool call (e.g. Anthropic's
// tool_use block id). Empty for text segments. Tool-execution events reuse it
// so the client can correlate an execution with its tool_call.* frames.
// ID 是某次工具调用的提供商块/索引标识(例如 Anthropic 的
// tool_use 块 id)。文本片段为空。工具执行事件复用它,
// 好让客户端能把一次执行与它的 tool_call.* 帧关联起来。
ID string
// Tool is the invoked tool's name on a tool_execution.* event. Empty on
// every other kind.
// Tool 是 tool_execution.* 事件上被调用工具的名字。
// 其它所有种类为空。
Tool string
// Phase is the fine-grained execution label (ToolPhase*) on a
// tool_execution.* event: running on start/update, done or error on end.
// Empty on every other kind.
// Phase 是 tool_execution.* 事件上的细粒度执行标签
// (ToolPhase*):start/update 时为 running,end 时为 done 或 error。
// 其它所有种类为空。
Phase string
// StreamID identifies the single model stream (one ChatStream call) a frame
// belongs to. The loop stamps it at the stream boundary so every frame of
// the same streamed message shares it, while a multi-step turn's later
// model calls get fresh ids; consumers distinguish it from TurnID, which
// stays constant across the whole turn.
// StreamID 标识某一帧所属的那一次模型流(一次 ChatStream 调用)。
// loop 在流边界打上它,使同一条流式消息的每一帧
// 共享它,而多步回合后续的模型调用会拿到新 id;
// 消费者据此把它与 TurnID 区分开——后者
// 在整个回合中保持不变。
StreamID string
// SessionID is the conversation the streamed event belongs to. It is stamped
// by the loop at the turn boundary (loop.streamStep), which reads the session
// the Run was started for; a model adapter, running below the loop and with no
// notion of a session, leaves it zero. A consumer that routes the event (the
// WS hub) reads it to target the right conversation.
// SessionID 是该流式事件所属的会话。它由 loop 在回合
// 边界打上(loop.streamStep),那里读取 Run 启动时所用的
// 会话;模型适配器跑在 loop 之下、没有
// 会话概念,所以留零值。路由该事件的消费者(WS hub)
// 读它来定位正确的会话。
SessionID string
// TurnID is the assistant turn the event belongs to. Like SessionID it is
// stamped by the loop, which reads the turn identity the caller attached with
// loop.WithTurnID; it is zero when no turn id was attached, so a consumer must
// treat both fields as best-effort correlation rather than a hard guarantee.
// TurnID 是事件所属的助手回合。与 SessionID 一样,它由
// loop 打上,那里读取调用方用
// loop.WithTurnID 附上的回合标识;未附回合 id 时为零,所以消费者必须
// 把这两个字段当作尽力而为的关联,而不是硬保证。
TurnID string
}
+46 -46
View File
@@ -1,27 +1,27 @@
package model
// Provider stop/finish reasons that mean the token budget cut the generation
// off: Anthropic reports stop_reason "max_tokens", OpenAI-compatible providers
// report finish_reason "length". Both are normalized into Response.StopReason
// verbatim so a trace keeps the provider's own vocabulary.
// 表示 token 预算把生成切断的提供商停止/结束原因:
// Anthropic 上报 stop_reason "max_tokens",OpenAI 兼容提供商
// 上报 finish_reason "length"。两者都被原样归一化进 Response.StopReason,
// 因此链路追踪保留提供商自己的词汇。
const (
stopReasonMaxTokens = "max_tokens"
stopReasonLength = "length"
)
// Truncation-recovery bounds. At most one retry with a doubled budget is
// allowed; the cap keeps a pathological budget from growing without bound. The
// hint is the non-empty placeholder returned when even the retry produced no
// visible text, so a truncated turn never surfaces as a silent empty reply.
// It never carries thinking: reasoning is not the answer.
// 截断恢复的边界。最多允许一次带翻倍预算的重试;
// 上限防止病态预算无界增长。hint 是连重试
// 也没产出可见文本时返回的非空占位,
// 因此被截断的回合绝不会以静默空回复的形式浮出。
// 它绝不携带思考:推理不是答案。
const (
truncationRetryCap = 32768
truncationEmptyHint = "(思考过长,未能生成回复)"
)
// effectiveMaxTokens resolves the budget an attempt actually sends: the explicit
// Request.MaxTokens, else the adapter default. def may be zero when the protocol
// has no adapter default, in which case the provider default applies.
// effectiveMaxTokens 解出一次尝试实际发送的预算:显式的
// Request.MaxTokens,否则用适配器默认值。当协议没有适配器
// 默认值时 def 可以为零,此时适用提供商默认值。
func effectiveMaxTokens(n, def int) int {
if n > 0 {
return n
@@ -29,10 +29,10 @@ func effectiveMaxTokens(n, def int) int {
return def
}
// retryMaxTokens doubles the effective budget for the one-shot truncation retry,
// capped at truncationRetryCap. It returns the effective budget unchanged when
// there is nothing to double (both n and def are zero, i.e. the provider default
// applies), letting the caller skip a retry that would send an identical request.
// retryMaxTokens 为一次性的截断重试翻倍实际生效预算,
// 上限为 truncationRetryCap。当没有什么可翻倍时(n 与 def 都是零,
// 即适用提供商默认值),它原样返回实际生效预算,从而让
// 调用方跳过那次会发出完全相同请求的重试。
func retryMaxTokens(n, def int) int {
eff := effectiveMaxTokens(n, def)
if eff <= 0 {
@@ -44,31 +44,31 @@ func retryMaxTokens(n, def int) int {
return truncationRetryCap
}
// isTruncatedStopReason reports whether a stop reason means the token budget cut
// the generation off.
// isTruncatedStopReason 报告某个停止原因是否意味着 token 预算
// 切断了生成。
func isTruncatedStopReason(reason string) bool {
return reason == stopReasonMaxTokens || reason == stopReasonLength
}
// isEmptyBody reports whether a response carries neither visible text nor tool
// calls. A tool-only round is NOT empty: its empty body is normal and belongs to
// the tool loop.
// isEmptyBody 报告响应是否既无可见文本也无工具
// 调用。仅工具的一轮并不是空:它的空正文是正常的、属于
// 工具循环。
func isEmptyBody(r Response) bool {
return r.Content == "" && len(r.ToolCalls) == 0
}
// isTruncatedEmpty reports whether an attempt was cut off by the token budget
// before producing any visible output (no text, no tool call). Only this shape
// triggers the truncation retry/fallback; a normal tool_use round, whose body is
// legitimately empty, does not.
// isTruncatedEmpty 报告某次尝试是否在产出任何可见输出之前
// 就被 token 预算切断(无文本、无工具调用)。只有这种形状
// 才触发截断重试/兜底;正常的 tool_use 回合,其正文
// 合法地为空,不会触发。
func isTruncatedEmpty(r Response) bool {
return isEmptyBody(r) && isTruncatedStopReason(r.StopReason)
}
// withEmptyBodyHint fills a non-empty placeholder body when a response has
// neither text nor tool calls, so a truncated turn never surfaces as a silent
// empty reply. A response that already carries text or tool calls is returned
// unchanged.
// withEmptyBodyHint 在响应既无文本也无工具调用时填入一段
// 非空占位正文,因此被截断的回合绝不会以静默
// 空回复的形式浮出。已携带文本或工具调用的响应
// 原样返回。
func withEmptyBodyHint(r Response) Response {
if !isEmptyBody(r) {
return r
@@ -78,12 +78,12 @@ func withEmptyBodyHint(r Response) Response {
return r
}
// callWithTruncationRetry runs a non-streaming attempt, retrying once with a
// doubled token budget when the first attempt was cut off with no visible
// output, then returning a non-empty placeholder when the retry still produced
// nothing. attempt receives the budget to send. An upstream error on the first
// attempt is returned unchanged (no retry); a failed retry falls back to the
// placeholder rather than surfacing an empty reply.
// callWithTruncationRetry 执行一次非流式尝试,当第一次尝试
// 在无可见输出下被切断时,用翻倍 token 预算重试一次,
// 随后在重试仍无产出时返回非空占位。attempt 接收要发送
// 的预算。第一次尝试的上游错误会被原样返回(不重试);
// 重试失败则退回占位,
// 而不是浮出一个空回复。
func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Response, error)) (Response, error) {
out, err := attempt(maxTokens)
if err != nil {
@@ -100,13 +100,13 @@ func callWithTruncationRetry(maxTokens, def int, attempt func(limit int) (Respon
return withEmptyBodyHint(out), nil
}
// callStreamWithTruncationRetry is the streaming counterpart of
// callWithTruncationRetry. attempt receives the budget and the event callback
// for that attempt. The retry drops thinking events (the first attempt already
// streamed its reasoning, so re-streaming it would duplicate the block) while
// forwarding text/tool events live; when even the retry yields no visible
// output, a non-empty placeholder is emitted as a text segment and returned as
// the response body, so the stream never ends on a silent truncation.
// callStreamWithTruncationRetry 是 callWithTruncationRetry 的
// 流式对位实现。attempt 接收该次尝试的预算与事件回调。
// 重试会丢弃思考事件(第一次尝试已经
// 流过它的推理,再流一次会产生重复块),
// 同时实时转发文本/工具事件;当连重试也没有可见
// 输出时,会以文本片段发出非空占位并把它作为
// 响应正文返回,因此流绝不会以静默截断收尾。
func callStreamWithTruncationRetry(
maxTokens, def int,
attempt func(limit int, onEvent func(StreamEvent)) (Response, error),
@@ -131,8 +131,8 @@ func callStreamWithTruncationRetry(
return out, nil
}
// dropThinking wraps onEvent so a retry attempt does not re-emit thinking
// segments. Text and tool_call events pass through unchanged.
// dropThinking 包裹 onEvent,使重试尝试不会再次发出思考
// 片段。文本与 tool_call 事件原样通过。
func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) {
if onEvent == nil {
return nil
@@ -147,8 +147,8 @@ func dropThinking(onEvent func(StreamEvent)) func(StreamEvent) {
}
}
// emitTextSegment emits a single-delta text segment so a synthesized placeholder
// reaches streaming consumers that render only message.* events.
// emitTextSegment 发出一个单增量的文本片段,使合成的占位
// 能到达只渲染 message.* 事件的流式消费者。
func emitTextSegment(onEvent func(StreamEvent), text string) {
if onEvent == nil || text == "" {
return
+27 -27
View File
@@ -14,9 +14,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// anthropicSeqServer answers successive /v1/messages calls with a fixed script
// of bodies (the last body repeats) and records every decoded request body, so a
// test can assert both the retry count and the retried max_tokens.
// anthropicSeqServer 用一份固定的响应体脚本回答连续的 /v1/messages
// 调用(最后一个响应体重复),并记录每个已解码的请求体,因此
// 测试既能断言重试次数、也能断言重试后的 max_tokens。
type anthropicSeqServer struct {
*httptest.Server
mu sync.Mutex
@@ -61,8 +61,8 @@ func (s *anthropicSeqServer) body(i int) map[string]any {
return s.bodies[i]
}
// anthropicThinkingOnlyResponse is a thinking-only, budget-truncated reply: the
// reasoning consumed the whole max_tokens budget, so no text block was emitted.
// anthropicThinkingOnlyResponse 是一份只有思考、被预算截断的回复:
// 推理吃光了整个 max_tokens 预算,所以没有文本块被发出。
const anthropicThinkingOnlyResponse = `{
"id":"msg_think","model":"claude-test",
"content":[{"type":"thinking","thinking":"SECRET-REASONING long long long"}],
@@ -84,8 +84,8 @@ const anthropicToolUseResponse = `{
"usage":{"input_tokens":5,"output_tokens":8}
}`
// anthropicTruncatedThinkingSSE is the streaming form of a thinking-only
// truncation: a thinking block, then message_delta stop_reason max_tokens.
// anthropicTruncatedThinkingSSE 是「只有思考」截断的流式形态:
// 一个 thinking 块,随后是 message_delta 的 stop_reason max_tokens。
const anthropicTruncatedThinkingSSE = `data: {"type":"message_start","message":{"model":"claude-test","usage":{"input_tokens":5}}}
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
@@ -114,9 +114,9 @@ data: {"type":"message_stop"}
`
// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget proves the
// primary recovery: a max_tokens truncation with only a thinking block retries
// exactly once with the token budget doubled and surfaces the retry's text.
// TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget 证明
// 主恢复路径:只有 thinking 块的 max_tokens 截断会
// 恰好重试一次、token 预算翻倍,并浮出重试的文本。
func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicEndTurnTextResponse)
@@ -149,9 +149,9 @@ func TestAnthropicChat_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T)
}
}
// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint proves the
// safety net: when the retry is truncated too, the caller gets a non-empty
// placeholder instead of a silent empty reply, and the thinking never leaks.
// TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint 证明
// 兜底网:连重试也被截断时,调用方拿到非空
// 占位,而不是静默空回复,且思考永不泄漏。
func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "application/json", anthropicThinkingOnlyResponse, anthropicThinkingOnlyResponse)
@@ -184,9 +184,9 @@ func TestAnthropicChat_TruncatedThinking_RetryStillEmpty_FallsBackToHint(t *test
}
}
// TestAnthropicChat_NormalStop_NoRetry proves a normal end_turn answer and a
// native tool_use round (whose body is legitimately empty) are left untouched:
// no retry, no fallback hint.
// TestAnthropicChat_NormalStop_NoRetry 证明正常的 end_turn 答复与
// 原生 tool_use 回合(其正文合法地为空)保持不动:
// 不重试、不补兜底占位。
func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) {
t.Parallel()
t.Run("end_turn", func(t *testing.T) {
@@ -224,9 +224,9 @@ func TestAnthropicChat_NormalStop_NoRetry(t *testing.T) {
})
}
// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget proves the
// streaming retry: the retry's thinking is suppressed (no duplicate block) while
// its text streams through, and the doubled budget is sent.
// TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget 证明
// 流式重试:重试产生的思考被抑制(无重复块),
// 而其文本流过,且发送的是翻倍预算。
func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicEndTurnTextSSE)
@@ -265,10 +265,10 @@ func TestAnthropicChatStream_TruncatedThinking_RetriesWithDoubledBudget(t *testi
}
}
// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint proves the
// streaming safety net: when the retry is truncated too, a non-empty placeholder
// is emitted as a text segment (and returned as the body) so the frontend is not
// left with a silent truncated turn.
// TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint 证明
// 流式兜底网:连重试也被截断时,会以文本片段发出非空
// 占位(并把它作为正文返回),因此前端不会
// 停在一个静默截断的回合上。
func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *testing.T) {
t.Parallel()
srv := newAnthropicSeqServer(t, "text/event-stream", anthropicTruncatedThinkingSSE, anthropicTruncatedThinkingSSE)
@@ -301,7 +301,7 @@ func TestAnthropicChatStream_TruncatedThinking_RetryStillEmpty_EmitsHint(t *test
}
}
// TestTruncationBudget covers the budget arithmetic that bounds the retry.
// TestTruncationBudget 覆盖约束重试的预算算术。
func TestTruncationBudget(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -328,9 +328,9 @@ func TestTruncationBudget(t *testing.T) {
}
}
// TestOpenAIChat_StopReasonMapping asserts the OpenAI adapter fills the unified
// Response.StopReason from finish_reason on both the non-streaming and streaming
// paths, so a "length" truncation is identifiable across protocols.
// TestOpenAIChat_StopReasonMapping 断言 OpenAI 适配器在非流式与
// 流式两条路径上都由 finish_reason 填充统一的
// Response.StopReason,因此 "length" 截断在各协议间都可识别。
func TestOpenAIChat_StopReasonMapping(t *testing.T) {
t.Parallel()
t.Run("non_streaming", func(t *testing.T) {
+122 -122
View File
@@ -1,31 +1,31 @@
// Package backthink implements the M8 autonomous background-think service
// (DevDocs/design/12-后台思考与主动.md §0-§3).
// backthink 包实现 M8 自主后台思考服务
// (DevDocs/design/12-后台思考与主动.md §0-§3)。
//
// It runs unattended thinking passes on two triggers:
// 它在两类触发下运行无人值守的思考轮:
//
// - A periodic heartbeat (config.Background.IntervalSec) that asks the injected
// runtime to reflect and is allowed to call tools (IoT, calendar, reminders)
// but must not emit chat-style messages by default.
// - Asynchronous events on the in-process bus (iot.device_update,
// reminder.trigger, system.event) that wake a thinking pass.
// - 周期性心跳(config.Background.IntervalSec),请求注入的运行时
// 反思,并允许调用工具(IoT、日历、提醒),
// 但默认不得发出 chat 风格的消息。
// - 进程内总线上的异步事件(iot.device_update、
// reminder.trigger、system.event),它们唤醒一轮思考。
//
// Each pass produces a conclusion whose structured tail decides whether she
// wants to speak at all (design/20 §2 P2), then consults Decide — the importance
// cascade restored as the production decision path — to judge whether now is an
// acceptable moment (event importance / time-of-day / user state such as heart
// rate, sleep, work, busy). A critical safety event punches through work/busy
// and quiet hours (裁决 07.4.2), and a heartbeat pass is never held by a
// long-silent activity gap (裁决 07.4.3). When it is worth interrupting the
// push goes to the active client first and falls back to the IM bridge. The
// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat
// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to
// same-event dedup (see event_dedup.go). Every pass is persisted to SQLite via
// Store so the M9/M10 monitor panel can render the background-thinking page
// (design/12 §4).
// 每轮产出一段结论,其结构化尾段决定她
// 到底想不想说话(design/20 §2 P2),随后咨询 Decide——
// 作为生产决策路径恢复的重要度级联——来判断此刻是否
// 合适(事件重要度 / 时段 / 用户状态,如心率、
// 睡眠、工作、忙碌)。critical 安全事件穿透工作/忙碌
// 与安静时段(裁决 07.4.2),而心跳轮永远不会被长期
// 静默的活动间隔拦下(裁决 07.4.3)。当值得打扰时,
// 推送先走活跃客户端,再降级到 IM 桥。
// DevDocs/design/11 §6 的主动推送频率门只约束
// 周期性心跳;事件驱动的推送绕过它(主人要求:"该发就发"),
// 但受同一事件去重约束(见 event_dedup.go)。每轮都经
// Store 持久化到 SQLite,好让 M9/M10 监控面板能渲染后台思考页
// (design/12 §4)。
//
// The package is concurrency-safe: a mutex guards the lifecycle state, the
// injected store/pusher/thinker are required to be concurrency-safe by contract,
// and the internal publish path never waits on a slow consumer.
// 本包并发安全:一把互斥锁守护生命周期状态,
// 注入的 store/pusher/thinker 按契约要求并发安全,
// 且内部发布路径绝不会等待慢消费者。
package backthink
import (
@@ -43,68 +43,68 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
)
// Sentinel errors returned by this package. Use errors.Is to test for them; they
// are returned directly or wrapped with %w so the underlying cause stays intact.
// 本包返回的哨兵错误。用 errors.Is 判定;它们被直接
// 返回或用 %w 包装,因此底层原因保持完整。
var (
// ErrNilDB is returned when Backthink or Store is built with a nil database.
// 用 nil 数据库构建 Backthink 或 Store 时返回 ErrNilDB。
ErrNilDB = errors.New("backthink: nil database handle")
// ErrNoThinker is returned when New is given no Thinker.
// New 未收到 Thinker 时返回 ErrNoThinker。
ErrNoThinker = errors.New("backthink: nil thinker")
// ErrNoLoop is returned by LoopThinker when it wraps a nil loop.
// LoopThinker 包装了 nil loop 时由它返回 ErrNoLoop。
ErrNoLoop = errors.New("backthink: nil loop")
)
// Thinker runs one unattended thinking pass over the injected runtime. It is the
// seam between the service and the loop/agent: the production wiring hands back a
// LoopThinker over a *loop.Loop, while tests inject a mock. Implementations may
// call tools (IoT, calendar, reminders) and must be concurrency-safe.
// Thinker 在注入的运行时上跑一轮无人值守的思考。它是
// 服务与 loop/agent 之间的接缝:生产接线交回一个基于
// *loop.Loop 的 LoopThinker,而测试注入 mock。实现可以
// 调用工具(IoT、日历、提醒),且必须并发安全。
type Thinker interface {
// Think invokes one autonomous thinking pass for the given session/trigger
// and returns the concluded text.
// Think 为给定的会话/触发调用一轮自主思考,
// 并返回结论文本。
Think(ctx context.Context, sessionID, trigger string) (string, error)
}
// Deps is the injected dependency set for a Backthink. Only Thinker and DB are
// required; the rest degrade gracefully (a nil Bus disables event triggers, a nil
// State yields a neutral user state, and a nil Pusher is built from Active/IM).
// Deps 是注入给 Backthink 的依赖集合。只有 Thinker 与 DB 是
// 必需的;其余优雅降级(Bus 为 nil 则关闭事件触发,State 为
// nil 则给出中性用户状态,Pusher 为 nil 则由 Active/IM 构建)。
type Deps struct {
// Bus is the event-trigger source. Nil disables event-driven thinking.
// Bus 是事件触发源。nil 则关闭事件驱动思考。
Bus bus.Bus
// Thinker runs one unattended thinking pass. Required.
// Thinker 跑一轮无人值守的思考。必需。
Thinker Thinker
// State supplies the user's current condition for the interrupt decision.
// Nil yields a neutral state.
// State 提供用户当前状况,用于打扰决策。
// nil 则给出中性状态。
State StateProvider
// Recent supplies the most recent main-session messages so the activity gate
// can sense whether the owner just chatted (recent-conversation cooldown and
// idle-window suppression). Nil disables activity sensing.
// Recent 提供主会话最近的消息,好让活动门
// 感知主人是否刚聊过(最近对话冷却与
// 静默窗口抑制)。nil 则关闭活动感知。
Recent recentProvider
// Pusher delivers a proactive message. When nil it is built from Active (the
// active-client sink) and IM (the IM bridge sink) plus Gate.
// Pusher 投递主动消息。为 nil 时由 Active(活跃客户端槽)
// 与 IM(IM 桥槽)加上 Gate 构建。
Pusher Pusher
// Active is the active web/native client sink, preferred for pushes.
// Active 是活跃 Web/原生客户端槽,推送首选。
Active ActiveClient
// IM is the IM bridge sink, used as the fallback when no active client is up.
// IM 是 IM 桥槽,在没有活跃客户端时用作降级。
IM IMAdapter
// Exporter mirrors a proactive message to the external push outlet (the
// owner's self-hosted unified push service, then Getui). Optional: nil
// disables the hook. It never blocks or breaks the Active/IM delivery.
// Exporter 把主动消息镜像到外部推送出口(主人的自托管
// 统一推送服务,其次是个推)。可选:nil 则
// 关闭该钩子。它绝不阻塞或破坏 Active/IM 投递。
Exporter PushExporter
// Gate is the proactive-push rate limiter. Nil builds a default from Config.
// Gate 是主动推送限流器。nil 则由 Config 构建默认值。
Gate PushGate
// DB is the SQLite handle for the think store. Required.
// DB 是思考存储所用的 SQLite 句柄。必需。
DB *sql.DB
// SessionID scopes a think. Empty uses defaultSessionID.
// SessionID 限定一次思考的作用域。为空则用 defaultSessionID。
SessionID string
// Now is the wall-clock source for decision/record timestamps. Nil uses
// time.Now, which makes time-dependent behaviour injectable in tests.
// Now 是决策/记录时间戳的墙钟来源。nil 则用
// time.Now,从而让依赖时间的行为在测试中可注入。
Now func() time.Time
// Log is the base logger, tagged with source "services/backthink".
// Log 是基础日志器,来源标记为 "services/backthink"。
Log *slog.Logger
}
// Backthink is the autonomous background-think service. Build it with New, start
// the heartbeat and subscriptions with Start, and stop them with Stop.
// Backthink 是自主后台思考服务。用 New 构建,用 Start 启动
// 心跳与订阅,用 Stop 停止它们。
type Backthink struct {
cfg Config
deps Deps
@@ -121,17 +121,17 @@ type Backthink struct {
subs []func()
wg sync.WaitGroup
// eventMu guards the same-event dedup guard (event_dedup.go). It is separate
// from the lifecycle lock so a dedup decision never blocks a sink swap.
// eventMu 守护同一事件去重器(event_dedup.go)。它与
// 生命周期锁分开,因此去重决策绝不会阻塞槽位换入。
eventMu sync.Mutex
lastEventKey string
}
// New builds a Backthink from cfg and deps, applying defaults and validating the
// required dependencies. It creates the SQLite think store (via Store) up front
// and resolves the push path (an injected Pusher, or a default built from
// Active/IM and the rate Gate). It returns ErrNilDB when DB is nil and
// ErrNoThinker when Thinker is nil.
// New 由 cfg 与 deps 构建 Backthink,套用默认值并校验
// 必需依赖。它先行创建 SQLite 思考存储(经 Store)
// 并解出推送路径(注入的 Pusher,或由
// Active/IM 与频率 Gate 构建的默认值)。DB 为 nil 时返回 ErrNilDB,
// Thinker 为 nil 时返回 ErrNoThinker。
func New(cfg Config, deps Deps) (*Backthink, error) {
if deps.Thinker == nil {
return nil, ErrNoThinker
@@ -147,7 +147,7 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
return nil, fmt.Errorf("backthink: new: %w", err)
}
// Resolve the gate up front so the sink setters can rebuild the pusher with it.
// 提前解出频率门,好让槽位设置器能与它一起重建推送器。
gate := deps.Gate
if gate == nil {
gate = newRateGate(cfg.PushLimit, cfg.PushWindow, cfg.PushCooldown, cfg.KindWindow, log)
@@ -178,10 +178,10 @@ func New(cfg Config, deps Deps) (*Backthink, error) {
}, nil
}
// Start begins the background work: the heartbeat ticker and, when a Bus is
// configured, the event subscriptions. It is idempotent and safe to call twice; a
// second call is a no-op. The provided ctx (may be nil) is the parent of all
// internal work: cancelling it stops the service exactly like Stop.
// Start 启动后台工作:心跳定时器,以及(配置了 Bus 时)
// 事件订阅。它是幂等的,调用两次安全;
// 第二次调用是空操作。传入的 ctx(可为 nil)是所有
// 内部工作的父级:取消它会像 Stop 一样停止服务。
func (b *Backthink) Start(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
@@ -209,11 +209,11 @@ func (b *Backthink) Start(ctx context.Context) {
"session_id", b.sessionID)
}
// Stop halts the heartbeat and unsubscribes every bus subscription, then waits
// for the in-flight thinking pass (bounded by ThinkTimeout) to drain. It is
// idempotent; a second call returns nil immediately. When ctx is cancelled before
// the drain completes it returns a wrapped context error rather than leaking the
// goroutine.
// Stop 停止心跳、退订所有总线订阅,然后等待
// 进行中的思考轮(受 ThinkTimeout 约束)排空。它是
// 幂等的;第二次调用立即返回 nil。若在排空完成前 ctx 已被
// 取消,则返回包装后的 context 错误,而不是泄漏
// goroutine。
func (b *Backthink) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
@@ -248,22 +248,22 @@ func (b *Backthink) Stop(ctx context.Context) error {
}
}
// List returns the most recent thinking records for the M9/M10 monitor panel,
// newest first. A non-positive limit selects the store default.
// List 返回 M9/M10 监控面板用的最近思考记录,
// 最新的在前。非正的 limit 选用存储默认值。
func (b *Backthink) List(ctx context.Context, limit int) ([]Record, error) {
return b.store.List(ctx, limit)
}
// triggers returns the bus subjects the service watches for event-driven
// thinking. It is a method (not a package var) so no package-level global slice
// appears, per the coding constraint.
// triggers 返回本服务为事件驱动思考所监听的总线主题。
// 它是一个方法(而非包级变量),因此不会出现包级全局
// 切片,符合编码约束。
func (b *Backthink) triggers() []string {
return []string{iot.DeviceUpdateSubject, SubjectReminderTrigger, SubjectSystemEvent}
}
// subscribe registers one handler per trigger subject. Each subscription is
// bound to ctx so cancelling it removes the registration; the unregister
// functions are kept so Stop can also tear them down explicitly.
// subscribe 为每个触发主题注册一个处理器。每个订阅都
// 绑定到 ctx,因此取消它会移除注册;退订函数
// 被保留下来,好让 Stop 也能显式拆除它们。
func (b *Backthink) subscribe(ctx context.Context) {
for _, subj := range b.triggers() {
unsub, err := b.deps.Bus.Subscribe(ctx, subj, b.onEvent)
@@ -277,11 +277,11 @@ func (b *Backthink) subscribe(ctx context.Context) {
}
}
// onEvent is the bus handler for event-triggered thinking. It runs on the bus's
// own worker goroutine, so it must not block on another subscriber. It first
// applies the minimal-disturbance gate (design/12 §6 策略一): a low-value system
// event is downgraded to a registration-only record rather than waking a full
// thinking pass, so platform/system noise cannot flood the thinker.
// onEvent 是事件驱动思考的总线处理器。它跑在总线自己的
// worker goroutine 上,所以不得阻塞在另一个订阅者上。它先
// 套用最小打扰门(design/12 §6 策略一):低价值的系统
// 事件被降级为仅登记一条记录,而不唤醒一整轮
// 思考,因此平台/系统噪声无法淹没思考器。
func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
tr := triggerForEvent(e)
if !b.shouldThink(tr) {
@@ -300,11 +300,11 @@ func (b *Backthink) onEvent(ctx context.Context, e bus.Event) error {
return nil
}
// shouldThink gates event-triggered thinking (design/12 §6 策略一). On a public
// platform a generic system event is low value, so only a high-importance
// (owner-relevant) system event wakes a pass. IoT device updates and reminder
// triggers are owner-relevant / high value and are never gated, so the productive
// triggers stay fully functional.
// shouldThink 为事件驱动思考把关(design/12 §6 策略一)。在公开
// 平台上,泛化的系统事件价值低,所以只有高重要度
// (与主人相关)的系统事件才唤醒一轮。IoT 设备更新与提醒
// 触发与主人相关 / 价值高,从不受门控,因此这些有产出的
// 触发保持完全可用。
func (b *Backthink) shouldThink(tr Trigger) bool {
if tr.Source == SubjectSystemEvent {
return tr.Import >= ImportanceHigh
@@ -312,11 +312,11 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
return true
}
// tickLoop is the heartbeat goroutine. Per 裁决 07.4.4 (design/12 §1) it thinks
// once immediately on process start — producing a humanized startup greeting —
// before settling into one thinking pass per tick; it exits when ctx is
// cancelled. The accepted side effect is that every process restart wakes one
// immediate thinking round.
// tickLoop 是心跳 goroutine。按裁决 07.4.4(design/12 §1),它在进程
// 启动时立即思考一次——产出一句拟人化的启动问候——
// 随后稳定为每 tick 一轮思考;ctx 被取消时
// 退出。可接受的副作用是每次进程重启都会唤醒
// 一轮即时思考。
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
@@ -339,10 +339,10 @@ func (b *Backthink) tickLoop(ctx context.Context) {
}
}
// think runs one unattended thinking pass: invoke the runtime, read the user
// state, decide whether to interrupt, push when warranted and persist the record.
// It is concurrency-safe provided the injected Thinker, StateProvider and Pusher
// are concurrency-safe, which they are by contract.
// think 跑一轮无人值守的思考:调用运行时、读取用户
// 状态、判断是否打扰、值得时推送并持久化记录。
// 只要注入的 Thinker、StateProvider 与 Pusher
// 并发安全,它就是并发安全的——按契约它们都是。
func (b *Backthink) think(parent context.Context, tr Trigger) {
at := b.now()
cctx, cancel := context.WithTimeout(parent, b.cfg.ThinkTimeout)
@@ -363,9 +363,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
return
}
// P2 (design/20 §2): her own conclusion decides whether she wants to speak
// and to which channel. parseProactive recovers the decision robustly and
// fails safe (malformed/ambiguous → no push).
// P2(design/20 §2):她自己的结论决定她想不想说、
// 以及发到哪个渠道。parseProactive 稳健地恢复该决策,
// 并失败安全(格式错乱/含糊 → 不推送)。
message, channelHint, push := parseProactive(conclusion)
state := b.currentState(cctx)
@@ -393,9 +393,9 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
pushRes := PushResult{Route: RouteNone, Detail: decision.Reason}
if shouldPush {
// Same-event dedup: a repeating identical event is suppressed after the
// first push (防刷), while a genuinely new event still goes out. Heartbeats
// are never deduped (they reset the chain) and stay gate-governed.
// 同一事件去重:重复的相同事件在首次推送后被抑制
// (防刷),而真正的新事件仍会发出。心跳
// 从不去重(它们会重置该链),并保持受门控管辖。
if b.eventRedundant(tr) {
pushRes = PushResult{Route: RouteNone, Detail: "duplicate event push suppressed"}
} else {
@@ -422,8 +422,8 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
}
}
// currentState reads the user's current condition for the interrupt decision,
// falling back to a neutral state (and a warn log) when the provider errors.
// currentState 读取用户当前状况用于打扰决策,
// 提供者报错时退回中性状态(并记一条 warn 日志)。
func (b *Backthink) currentState(ctx context.Context) UserState {
if b.deps.State == nil {
return UserState{}
@@ -436,12 +436,12 @@ func (b *Backthink) currentState(ctx context.Context) UserState {
return s
}
// lastUserActivity reports the wall-clock time of the owner's most recent message
// in the main session, and whether any user message was found. It is the activity
// signal for the activity gate (design/12 §6): a nil provider, a failed read or an
// empty tail yield has=false so the activity gate falls through to the current
// behaviour rather than guessing. The most recent user message is the newest
// RoleUser entry in the recent tail (Recent returns oldest-first).
// lastUserActivity 报告主人最近一条消息的墙钟时间
// (在主会话里),以及是否找到了任何用户消息。它是活动
// 门的活动信号(design/12 §6):提供者为 nil、读取失败或
// 尾部为空都得到 has=false,因此活动门会落到当前
// 行为而不是靠猜。最近的一条用户消息是最近尾部里
// 最新的 RoleUser 条目(Recent 返回的是最旧在前)。
func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
if b.deps.Recent == nil {
return time.Time{}, false
@@ -458,14 +458,14 @@ func (b *Backthink) lastUserActivity(ctx context.Context) (time.Time, bool) {
return time.Time{}, false
}
// record persists a thinking pass. It uses a background context so an audit row
// is written even when the think's own context has already been cancelled.
// record 持久化一轮思考。它使用后台 context,因此即便
// 该次思考自身的 context 已被取消,审计行仍会写入。
func (b *Backthink) record(r Record) {
if _, err := b.store.Append(context.Background(), r); err != nil {
b.log.Error("backthink record failed", "error", err)
}
// Surface the persisted pass on the WS stream so the frontend's backthink
// consumer (or its default fallback) observes it without polling the store.
// 把持久化的这一轮暴露到 WS 流上,好让前端的 backthink
// 消费者(或其默认兜底)无需轮询存储就能观测到它。
if b.deps.Bus != nil {
if err := b.deps.Bus.Publish(context.Background(), "ws.backthink", r); err != nil {
b.log.Warn("backthink bus publish failed", "error", err)
+27 -27
View File
@@ -11,8 +11,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// newBackthink builds a Backthink with a fresh in-memory DB and the given deps,
// returning the zeroed deps filled with the DB handle so tests can inspect deps.
// newBackthink 用一份新的内存 DB 与给定 deps 构建 Backthink,
// 并返回填好 DB 句柄的 deps,好让测试能检查 deps。
func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) {
t.Helper()
deps.DB = newTestDB(t)
@@ -34,9 +34,9 @@ func TestNew_Validation(t *testing.T) {
}
}
// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute
// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a
// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window.
// TestConfig_Defaults 验证收紧后的主动推送默认值:15 分钟
// 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、
// 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。
func TestConfig_Defaults(t *testing.T) {
t.Parallel()
cfg := normalize(Config{})
@@ -192,8 +192,8 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) {
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
t.Parallel()
// design/12 §6 策略一: a low-value system event on a public platform does not
// wake a full thinking pass — it is downgraded to a registration-only record.
// design/12 §6 策略一:公开平台上的低价值系统事件不会
// 唤醒一整轮思考——它被降级为仅登记一条记录。
thinker := &mockThinker{text: "should not be called"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
@@ -240,8 +240,8 @@ func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
t.Parallel()
// A high-importance (owner-relevant) system event still wakes a thinking pass —
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
// 高重要度(与主人相关)的系统事件仍会唤醒一轮思考——
// 降噪门只降级低价值噪声(design/12 §6 策略一)。
thinker := &mockThinker{text: "important notice"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
@@ -289,8 +289,8 @@ func TestBackthink_List_NewestFirst(t *testing.T) {
if err != nil {
t.Fatalf("List: %v", err)
}
// The single-row listing must be the same newest record as the head of the
// full listing, and the full listing must be strictly newest-first.
// 单行列表里的那一条必须与完整列表的头部是同一条最新
// 记录,且完整列表必须是严格的由新到旧。
if top[0].ID != all[0].ID {
t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID)
}
@@ -312,7 +312,7 @@ func TestBackthink_Stop_NoLeak(t *testing.T) {
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
// Idempotent.
// 幂等。
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("second Stop: %v", err)
}
@@ -331,9 +331,9 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker})
b.Start(context.Background())
// Confirm the subscription is live by triggering one think (identified by its
// persisted record, so the 07.4.4 startup round and heartbeat ticks with their
// own trigger strings cannot mask or overwrite it).
// 通过触发一次思考来确认订阅是活的(以它持久化的
// 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的
// 触发字符串不会掩盖或覆盖它)。
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil {
t.Fatalf("Publish: %v", err)
}
@@ -363,10 +363,10 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
}
}
// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a
// repeating identical event (same source + summary) is suppressed after the first
// push (防刷), while a genuinely different event still goes out. The duplicate is
// recorded as suppressed (route none), not delivered.
// TestBackthink_EventPush_DedupSameEvent 验证同一事件去重门:
// 重复的相同事件(同源 + 同摘要)在首次推送后
// 被抑制(防刷),而真正不同的事件仍会发出。重复的那条
// 被记为已抑制(route none),而不是投递出去。
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
@@ -382,9 +382,9 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev) // first: pushes
b.think(context.Background(), ev) // second: same event -> suppressed
b.think(context.Background(), ev) // third: same event -> suppressed
b.think(context.Background(), ev) // 第一次:推送
b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制
b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
@@ -396,7 +396,7 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
if len(recs) < 3 {
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
}
// Newest first: the two most recent records are the suppressed duplicates.
// 最新的在前:最近的这两条就是被抑制的重复。
for i := 0; i < 2; i++ {
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
@@ -405,12 +405,12 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
}
}
// The third-most-recent record is the first (delivered) push.
// 第三新的那条是首次(已投递的)推送。
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
}
// A genuinely different event resets the chain and goes out.
// 真正不同的事件会重置该链并发出。
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
}})
@@ -418,8 +418,8 @@ func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
if got := pusher.count(); got != 2 {
t.Fatalf("pusher calls after a different event = %d, want 2", got)
}
// After the different event, the original identical event is no longer a
// consecutive duplicate, so it pushes again — the guard only dedups consecutively.
// 在出现不同事件之后,原先那个相同事件不再是
// 连续重复,因此它会再次推送——该门只去重连续重复。
b.think(context.Background(), ev)
if got := pusher.count(); got != 3 {
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
+53 -53
View File
@@ -9,83 +9,83 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Defaults applied by normalize when a Config field is left at its zero value.
// They are compile-time constants so no magic number appears in the run path.
// normalize 在 Config 字段留为零值时套用的默认值。
// 它们是编译期常量,因此运行路径里不出现魔法数字。
const (
// defaultIntervalSec is the heartbeat period in seconds when unused
// (15 minutes: the autonomous reflection is deliberately low-frequency).
// defaultIntervalSec 是未使用时的心跳周期秒数
// (15 分钟:自主反思刻意保持低频)。
defaultIntervalSec = 900
// defaultQuietStartHour and defaultQuietEndHour bound the do-not-disturb
// window in local clock hours (00:00-07:00 vs 23:00-07:00: the window may
// wrap midnight; two equal non-zero hours disable it).
// defaultQuietStartHour 与 defaultQuietEndHour 以本地钟点约束免打扰
// 窗口(00:00-07:00 对 23:00-07:00:窗口可以
// 跨午夜;两个相等的非零钟点会禁用该窗口)。
defaultQuietStartHour = 23
defaultQuietEndHour = 7
// defaultPushLimit caps proactive pushes within defaultPushWindow to honor
// the anti-spam rule of design/11 §6 (QQ wind control, iLink window). The
// default drops to 1 so the owner is interrupted at most once per hour.
// defaultPushLimit 限制 defaultPushWindow 内的主动推送次数,以遵守
// design/11 §6 的反骚扰规则(QQ 风控、iLink 窗口)。
// 默认降到 1,因此主人每小时最多被打扰一次。
defaultPushLimit = 1
defaultPushWindow = 60 * time.Minute
// defaultPushCooldown is the quiet gap after a successful proactive push,
// so a successful delivery is not immediately repeated.
// defaultPushCooldown 是一次成功主动推送之后的安静间隔,
// 以免一次成功投递被立即重复。
defaultPushCooldown = 30 * time.Minute
// defaultRecentChatCooldown is the gap after the owner's last message within
// which an autonomous push is held, so a fresh conversation is not interrupted.
// defaultRecentChatCooldown 是主人最后一条消息之后的间隔,
// 在此期间自主推送被扣住,以免打断刚开始的对话。
defaultRecentChatCooldown = 10 * time.Minute
// defaultKindWindow bounds how often any single push flavor (问候/提醒/分享/
// 关心) may repeat, so the same style cannot spam within it.
// defaultKindWindow 限制任何一种推送风格(问候/提醒/分享/
// 关心)的重复频率,使同一风格无法在窗口内刷屏。
defaultKindWindow = 2 * time.Hour
// defaultThinkTimeout bounds a single unattended thought so it can never
// wedge a worker goroutine.
// defaultThinkTimeout 约束单次无人值守思考,因此它绝不会
// 卡住 worker goroutine。
defaultThinkTimeout = 30 * time.Second
// defaultSessionID scopes a think when the caller supplies none. It is the
// loop.Run durability key, deliberately distinct from history.MainSessionID so
// an autonomous think never collides with the live main session's checkpoint
// store (channelbridge saves/resumes under the main id). The think still sees
// the main conversation: LoopThinker reads history.MainSessionID for context and
// runs under loop.OwnerEnv (design/20 §2).
// defaultSessionID 在调用方未提供时限定一次思考的作用域。它是
// loop.Run 的持久化键,刻意与 history.MainSessionID 不同,
// 这样自主思考绝不会与活跃主会话的检查点
// 存储冲突(channelbridge 在主 id 下保存/恢复)。该次思考仍能看到
// 主对话:LoopThinker 读 history.MainSessionID 取上下文,
// 并在 loop.OwnerEnv 下运行(design/20 §2)。
defaultSessionID = "sess-backthink"
)
// DefaultSessionID is the exported loop.Run session key for an autonomous think. It is
// exposed so the flow-viz publisher (cmd/cyrene) can also publish the backthink flow
// graph, which the backthink page and the web-chat flow page both render.
// DefaultSessionID 是自主思考所用的、对外暴露的 loop.Run 会话键。它
// 之所以暴露,是为了让流程可视化发布器(cmd/cyrene)也能发布 backthink
// 流程图,供 backthink 页与 Web 聊天流程页共同渲染。
const DefaultSessionID = "sess-backthink"
// Config carries the autonomous-think tuning knobs. Zero values pick safe
// defaults via normalize, so a partially filled Config is still usable.
// Config 携带自主思考的调优旋钮。零值会经 normalize
// 选取安全默认值,因此半填的 Config 仍可用。
type Config struct {
// Interval is the heartbeat period. When zero it is derived from
// IntervalSec (or its default).
// Interval 是心跳周期。为零时由
// IntervalSec(或其默认值)推导。
Interval time.Duration
// IntervalSec is the heartbeat period in seconds, mapped from
// config.BackgroundConfig.IntervalSec. Ignored when Interval is set.
// IntervalSec 是心跳周期秒数,映射自
// config.BackgroundConfig.IntervalSec。设置了 Interval 时忽略。
IntervalSec int
// QuietStartHour and QuietEndHour bound the do-not-disturb window in local
// clock hours. Equal non-zero hours disable the window.
// QuietStartHour 与 QuietEndHour 以本地钟点约束免打扰窗口。
// 两个相等的非零钟点会禁用该窗口。
QuietStartHour int
QuietEndHour int
// PushLimit bounds proactive pushes within PushWindow. Zero uses the default.
// Non-positive PushWindow or PushLimit disables the limit.
// PushLimit 限制 PushWindow 内的主动推送次数。为零用默认值。
// 非正的 PushWindow 或 PushLimit 会禁用该限制。
PushLimit int
// PushWindow is the sliding window over which PushLimit is enforced.
// PushWindow 是执行 PushLimit 的滑动窗口。
PushWindow time.Duration
// PushCooldown is the quiet gap after a successful proactive push before
// another is allowed. Zero uses the default.
// PushCooldown 是一次成功主动推送之后、允许下一次之前的
// 安静间隔。为零用默认值。
PushCooldown time.Duration
// RecentChatCooldown is the gap after the owner's last message within which an
// autonomous push is held. Zero uses the default.
// RecentChatCooldown 是主人最后一条消息之后的间隔,在此期间
// 自主推送被扣住。为零用默认值。
RecentChatCooldown time.Duration
// KindWindow bounds how often any single push flavor may repeat. Zero uses the
// default.
// KindWindow 限制任何一种推送风格可重复的频率。为零用
// 默认值。
KindWindow time.Duration
// ThinkTimeout bounds a single thinking pass. Zero uses the default.
// ThinkTimeout 约束单轮思考。为零用默认值。
ThinkTimeout time.Duration
}
// ConfigFromBackground maps a config.BackgroundConfig (the source of the heartbeat
// interval, the shared quiet-hours window and the proactive-push frequency knobs)
// into a backthink.Config, leaving anything the env does not expose at its
// defaults for normalize to fill in.
// ConfigFromBackground 把 config.BackgroundConfig(心跳周期、
// 共享安静时段窗口与主动推送频次旋钮的来源)
// 映射成 backthink.Config,env 未暴露的项保持其
// 默认值,交给 normalize 填。
func ConfigFromBackground(bg config.BackgroundConfig) Config {
return Config{
IntervalSec: bg.IntervalSec,
@@ -99,7 +99,7 @@ func ConfigFromBackground(bg config.BackgroundConfig) Config {
}
}
// normalize fills a Config's zero fields with safe defaults.
// normalize 用安全默认值填充 Config 的零值字段。
func normalize(cfg Config) Config {
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = defaultIntervalSec
@@ -132,13 +132,13 @@ func normalize(cfg Config) Config {
return cfg
}
// quietHoursLabel renders the do-not-disturb window for logs.
// quietHoursLabel 为日志渲染免打扰窗口。
func quietHoursLabel(start, end int) string {
return fmt.Sprintf("%02d:00-%02d:00", start, end)
}
// baseLog returns l when non-nil, otherwise a discard logger, so New always has a
// safe logger to tag.
// baseLog 在 l 非 nil 时返回 l,否则返回丢弃式日志器,好让 New 总是有
// 一个可标记的安全日志器。
func baseLog(l *slog.Logger) *slog.Logger {
if l != nil {
return l
+32 -32
View File
@@ -7,24 +7,24 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// recentContextCount is how many of the most recent main-session messages are
// folded into one autonomous think as conversation context. Compile-time so no
// magic number appears in the run path.
// recentContextCount 是折进一次自主思考作为对话上下文的最新
// 主会话消息条数。编译期常量,因此
// 运行路径里不出现魔法数字。
const recentContextCount = 20
// recentProvider returns the most recent messages of a session in oldest-first
// order, the tail an autonomous think reads to "see" the main conversation.
// *history.Store satisfies it; tests inject a fake. It is the narrow slice of
// history.HistoryStore that the thinker actually depends on, matching the loop's
// own checkpointStore style of minimal seam interfaces.
// recentProvider 以最旧在前的顺序返回会话最近的消息,
// 即一次自主思考为「看到」主对话而读取的尾部。
// *history.Store 满足它;测试注入 fake。它是 thinker 实际
// 依赖的那一小片 history.HistoryStore,与 loop 自己的
// checkpointStore 风格的最小接缝接口一致。
type recentProvider interface {
Recent(ctx context.Context, sessionID string, n int) ([]history.Message, error)
}
// mainContext returns the most recent main-session messages converted to model
// messages (oldest-first) for one autonomous think. A nil provider, a failed read
// or an empty tail all yield nil so the think degrades to the guidance prompt
// alone rather than failing the pass (design/20 §2).
// mainContext 把最近的主会话消息转换为 model 消息
// (最旧在前),供一次自主思考使用。提供者为 nil、读取失败
// 或尾部为空都得到 nil,因此该次思考降级为仅用引导提示词,
// 而不是让这一轮失败(design/20 §2)。
func mainContext(ctx context.Context, prov recentProvider) []model.Message {
if prov == nil {
return nil
@@ -40,9 +40,9 @@ func mainContext(ctx context.Context, prov recentProvider) []model.Message {
return msgs
}
// mapHistoryMessage converts one persisted chat message into a model message,
// translating the history role and preserving an image part when the message
// carries one so a multimodal main model still sees it.
// mapHistoryMessage 把一条持久化的 chat 消息转换为 model 消息,
// 翻译历史角色,并在消息携带图片时保留该图片片段,
// 因此多模态主模型仍能看到它。
func mapHistoryMessage(h history.Message) model.Message {
msg := model.Message{
Role: historyRoleToModel(h.Role),
@@ -54,8 +54,8 @@ func mapHistoryMessage(h history.Message) model.Message {
return msg
}
// historyRoleToModel maps a persisted chat role to the model's role vocabulary,
// defaulting unknown roles to the user lane.
// historyRoleToModel 把持久化的 chat 角色映射到 model 的角色词汇,
// 未知角色默认为 user 通道。
func historyRoleToModel(role string) model.Role {
switch role {
case history.RoleAssistant:
@@ -67,9 +67,9 @@ func historyRoleToModel(role string) model.Role {
}
}
// thinkInput assembles the full input for one unattended think: the recent
// main-session context (if any) followed by a single guidance prompt turn, so the
// runtime reflects on real conversation rather than an empty macro prompt.
// thinkInput 拼装一次无人值守思考的完整输入:最近的主会话语境
// (若有)后跟单个引导提示词回合,因此
// 运行时针对真实对话反思,而不是空泛的宏提示词。
func thinkInput(recent []model.Message, trigger string) []model.Message {
out := make([]model.Message, 0, len(recent)+1)
out = append(out, recent...)
@@ -77,19 +77,19 @@ func thinkInput(recent []model.Message, trigger string) []model.Message {
return out
}
// guidancePrompt is the instruction handed to the runtime for one unattended
// autonomous thought. Unlike the old backgroundPrompt it deliberately encourages a
// warm, targeted proactive message and drops the "do not produce a chat-style
// message" suppression. Identity/permission stay on the actor metadata + owner env,
// never on this prompt text (red line); the prompt only describes the running
// environment (memories readable/writable, tools callable) that the env already
// grants.
// guidancePrompt 是交给运行时的一次无人值守自主思考的
// 指令。与旧 backgroundPrompt 不同,它刻意鼓励
// 温暖、有针对性的主动消息,并去掉「不要产生 chat 风格
// 消息」的抑制。身份/权限始终在 actor 元数据 + owner env 上,
// 绝不在这段提示词文本上(红线);提示词只描述运行
// 环境(记忆可读/可写、工具可调用),而那是 env 已经
// 授予的。
//
// The prompt asks for a compact structured closing — a push/no_push decision, an
// optional target channel and the message body — so the service can honor her own
// judgment instead of gating on the trigger's importance (design/20 §2 P2). The
// format is guidance, not a contract: parseProactive fails safe when she omits or
// mangles it.
// 提示词要求一个紧凑的结构化收尾——push/no_push 决策、
// 可选的目标渠道与消息正文——好让服务尊重她自己的
// 判断,而不是按触发的重要度把关(design/20 §2 P2)。该
// 格式是引导而非契约:她省略或
// 写乱该格式时,parseProactive 失败安全。
//
// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的
// 「回来」问候。
+63 -63
View File
@@ -6,26 +6,26 @@ import (
"time"
)
// Importance is the estimated severity of an event that woke a thinking pass.
// Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her
// conclusion does); it decides the hard-gate exemptions: a critical safety event
// punches through work/busy and quiet hours (裁决 07.4.2), and a high event still
// passes quiet hours (the pre-existing cascade semantics).
// Importance 是唤醒一轮思考的事件估计严重度。
// 在 design/20 §2 P2 之下,它不再决定她*想不想*说话(那由她的
// 结论决定);它决定硬门豁免:critical 安全事件
// 穿透工作/忙碌与安静时段(裁决 07.4.2),而 high 事件仍
// 通过安静时段(既有的级联语义)。
type Importance int
const (
// ImportanceLow is a routine trigger (e.g. the periodic heartbeat).
// ImportanceLow 是例行触发(如周期性心跳)。
ImportanceLow Importance = iota
// ImportanceNormal is an ordinary event worth reflecting on (device change).
// ImportanceNormal 是值得反思的普通事件(设备变化)。
ImportanceNormal
// ImportanceHigh is a notable event (a reminder the user set firing).
// ImportanceHigh 是值得注意的事件(用户设置的提醒触发)。
ImportanceHigh
// ImportanceCritical is a safety-critical event that punches through
// work/busy and quiet hours (裁决 07.4.2 安全域豁免).
// ImportanceCritical 是穿透
// 工作/忙碌与安静时段的安全关键事件(裁决 07.4.2 安全域豁免)。
ImportanceCritical
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (i Importance) String() string {
switch i {
case ImportanceLow:
@@ -41,55 +41,55 @@ func (i Importance) String() string {
}
}
// longIdleThreshold is the gap after the owner's last message beyond which the
// activity gate only lets a genuinely important event through — except heartbeat
// passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time
// so no magic number appears.
// longIdleThreshold 是主人最后一条消息之后的间隔,超过它活动门
// 只放行真正重要的事件——但心跳轮除外,
// 它们永远不会被长期静默的间隔拦下(裁决 07.4.3)。编译期
// 常量,因此不出现魔法数字。
const longIdleThreshold = 2 * time.Hour
// UserState describes the user's current condition as observed from device
// telemetry (heart rate, sleep) and schedule (work, busy). It is the negative
// signal in the interrupt decision: a sleeping, working or busy user is left
// alone. Boredom (0..1) is the soft positive dial that widens interruption.
// UserState 描述由设备遥测(心率、睡眠)与日程(工作、忙碌)
// 观察到的用户当前状况。它是打扰决策里的负向
// 信号:睡眠、工作或忙碌中的用户不被打扰。
// 无聊度(0..1)是放宽打扰的软性正向旋钮。
type UserState struct {
// HeartRateBPM is the current heart rate, 0 when unknown.
// HeartRateBPM 是当前心率,未知时为 0。
HeartRateBPM int
// Sleeping reports whether the user is currently asleep from sleep tracking.
// Sleeping 报告睡眠追踪显示用户当前是否睡着。
Sleeping bool
// Working reports whether the user is in a work/scheduled block.
// Working 报告用户是否处于工作/日程时段。
Working bool
// Busy reports whether the user is marked busy (calendar / do-not-disturb).
// Busy 报告用户是否被标记为忙碌(日历 / 免打扰)。
Busy bool
// Boredom ranges 0..1. Higher boredom means the user is idle and more open
// to a gentle interruption. Under design/20 §2 P2 the push intent is her own
// conclusion, so this dial no longer gates the decision; it is kept as a
// telemetry signal her judgment already absorbs.
// Boredom 取值 0..1。无聊度越高表示用户越空闲、越愿意
// 被温和地打扰。在 design/20 §2 P2 之下推送意图来自她自己的
// 结论,所以这个旋钮不再为决策把关;它作为一个
// 她的判断本就会吸收的遥测信号保留下来。
Boredom float64
}
// String returns a concise human-readable summary for logs/records.
// String 返回用于日志/记录的简洁可读摘要。
func (s UserState) String() string {
return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f",
s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom)
}
// DecisionInput is the set of signals Decide weighs: the event's importance, the
// current wall-clock time, the user's current state and their boredom.
// DecisionInput 是 Decide 所称量的信号集合:事件重要度、
// 当前墙钟时间、用户当前状态及其无聊度。
type DecisionInput struct {
// Trigger is a human-readable source label, recorded for traceability.
// Trigger 是可读的来源标签,为可追溯而记录。
Trigger string
// Import is the event's estimated importance.
// Import 是事件的估计重要度。
Import Importance
// At is the current wall-clock time, in the local timezone used for the
// do-not-disturb window.
// At 是当前墙钟时间,处于免打扰窗口所用的
// 本地时区。
At time.Time
// UserState carries the user's physiological/schedule condition.
// UserState 携带用户的生理/日程状况。
UserState UserState
// LastActivity is the wall-clock time of the owner's most recent message.
// HasActivity reports whether a user message was found; when false the
// activity gate falls through without guessing.
// LastActivity 是主人最近一条消息的墙钟时间。
// HasActivity 报告是否找到了用户消息;为 false 时
// 活动门直接落空、不靠猜。
LastActivity time.Time
// HasActivity reports whether LastActivity is meaningful.
// HasActivity 报告 LastActivity 是否有意义。
HasActivity bool
// ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」
// 由她的结论决定,本决策只回答「此刻能不能说」。
@@ -99,11 +99,11 @@ type DecisionInput struct {
Heartbeat bool
}
// Decision is the outcome of Decide.
// Decision 是 Decide 的结果。
type Decision struct {
// ShouldPush reports whether the user should be interrupted.
// ShouldPush 报告是否应打扰用户。
ShouldPush bool
// Reason is a short human-readable justification, surfaced on the panel.
// Reason 是简短可读的理由,会在面板上呈现。
Reason string
}
@@ -192,9 +192,9 @@ func (b *Backthink) activityHeld(in DecisionInput) (bool, string) {
return false, ""
}
// inQuietHours reports whether at falls inside the configured do-not-disturb
// window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero
// hours disable it.
// inQuietHours 报告 at 是否落在已配置的免打扰
// 窗口内。窗口可以跨午夜(如 23:00-07:00);两个相等的非零
// 钟点会禁用它。
func (b *Backthink) inQuietHours(at time.Time) bool {
h := at.Hour()
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
@@ -204,24 +204,24 @@ func (b *Backthink) inQuietHours(at time.Time) bool {
return h >= start || h < end
}
// PushKind classifies a proactive message into one of a few flavors so the
// frequency gate can limit each flavor within a window, avoiding same-style spam
// (design/12 §6 策略二). It is a soft tag derived from the message body's
// keywords — it affects pacing, never identity or permission.
// PushKind 把主动消息归类成几种风格,好让
// 频率门能在窗口内限制每种风格,避免同一风格刷屏
// (design/12 §6 策略二)。它是由消息正文关键词导出的软标签
// ——它只影响节奏,绝不影响身份或权限。
type PushKind int
const (
// KindGreeting is a warm hello / good-morning / good-night opener.
// KindGreeting 是温暖的打招呼 / 早安 / 晚安开场。
KindGreeting PushKind = iota
// KindReminder is a nudge about a schedule, task or deadline.
// KindReminder 是关于日程、任务或截止时间的提醒。
KindReminder
// KindShare is a discovery, observation or interesting find.
// KindShare 是发现、观察或有意思的见闻。
KindShare
// KindCare is a warm, caring check-in about the owner's wellbeing.
// KindCare 是对主人身心状况的温暖关心问候。
KindCare
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (k PushKind) String() string {
switch k {
case KindGreeting:
@@ -237,11 +237,11 @@ func (k PushKind) String() string {
}
}
// classifyPush assigns a PushKind to a proactive message from its keyword
// content. A message that matches no flavor defaults to KindCare (the warm,
// generic intent of an unsolicited proactive note), which is the least
// spam-prone grouping. Priority is reminder > care > greeting > share so the
// dominant intent wins when several keywords co-occur.
// classifyPush 按关键词内容给主动消息指派 PushKind。
// 不匹配任何风格的消息默认为 KindCare(一条主动问候的
// 温暖、泛化意图),那是最不容易
// 刷屏的分组。优先级为 reminder > care > greeting > share,
// 因此多个关键词共现时主流意图获胜。
func classifyPush(message string) PushKind {
switch {
case hasAnyKeyword(message, kindReminderKeywords...):
@@ -257,9 +257,9 @@ func classifyPush(message string) PushKind {
}
}
// keyword sets for classifyPush. They are package vars (not consts) so the
// classification stays flexible and testable, but they are immutable by
// convention and never mutated at run time.
// classifyPush 所用的关键词集合。它们是包级变量(非常量)以便
// 分类保持灵活可测,但按约定不可变,
// 从不在运行时改写。
var (
kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"}
kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"}
@@ -267,7 +267,7 @@ var (
kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"}
)
// hasAnyKeyword reports whether s contains any of the given substrings.
// hasAnyKeyword 报告 s 是否包含给定子串中的任意一个。
func hasAnyKeyword(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
+15 -15
View File
@@ -5,14 +5,14 @@ import (
"time"
)
// dayAt returns a mid-afternoon time in UTC (outside the quiet-hours window).
// dayAt 返回 UTC 的午后时间(在安静时段窗口之外)。
func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) }
// nightAt returns a 02:00 time in UTC (inside the default 23-7 quiet window).
// nightAt 返回 UTC 的 02:00(在默认 23-7 安静窗口之内)。
func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) }
// testBackthink builds a Backthink with a normalized empty Config so Decide uses
// the default quiet-hours window (23:00-07:00).
// testBackthink 用归一化的空 Config 构建 Backthink,因此 Decide 使用
// 默认安静时段窗口(23:00-07:00)。
func testBackthink() *Backthink {
return &Backthink{cfg: normalize(Config{})}
}
@@ -144,8 +144,8 @@ func TestInQuietHours(t *testing.T) {
}
}
// TestClassifyPush verifies the proactive message body maps to the intended flavor
// (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately.
// TestClassifyPush 验证主动消息正文映射到预期的风格
// (问候/提醒/分享/关心),使频率门能分别限制每种风格。
func TestClassifyPush(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -168,16 +168,16 @@ func TestClassifyPush(t *testing.T) {
}
}
// TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh
// conversation holds trivia (unless critical), a long-idle owner is only woken by
// an important event — except a heartbeat pass, which is never held by a
// long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress.
// TestDecide_ActivityGate 验证活动感知软门:刚开始的
// 对话会扣下琐碎内容(critical 除外),长期静默的主人只会被
// 重要事件唤醒——但心跳轮除外,它永远不会被长期
// 静默的间隔拦下(裁决 07.4.3)——而中间地带不会过度抑制。
func TestDecide_ActivityGate(t *testing.T) {
t.Parallel()
b := testBackthink()
// Just chatted (within RecentChatCooldown=10min): a normal daytime push is
// held unless the event is safety-critical.
// 刚聊过(在 RecentChatCooldown=10min 内):白天普通推送
// 会被扣下,除非该事件是安全关键。
fresh := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
@@ -194,8 +194,8 @@ func TestDecide_ActivityGate(t *testing.T) {
t.Fatalf("critical event should push even in a fresh conversation, got %+v", got)
}
// Away a long while (> longIdleThreshold): only an important event is worth
// it — but a heartbeat pass still goes through (裁决 07.4.3 长期静默段不拦心跳).
// 离开很久(> longIdleThreshold):只有重要事件值得
// 唤醒——但心跳轮仍会通过(裁决 07.4.3 长期静默段不拦心跳)。
idle := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
@@ -217,7 +217,7 @@ func TestDecide_ActivityGate(t *testing.T) {
t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got)
}
// Middling activity (30 min ago) is not over-suppressed.
// 中等活动(30 分钟前)不会被过度抑制。
middle := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
+11 -11
View File
@@ -1,20 +1,20 @@
package backthink
// eventRedundant reports whether tr is a duplicate of the immediately preceding
// event-driven push (same source + summary), so a repeating device/reminder event
// does not spam the owner (防刷). It is the one guard that still applies to an
// event-driven push while the PushGate itself is bypassed for events (owner
// mandate: "该发就发" — a genuine event goes out, only a true duplicate is held).
// eventRedundant 报告 tr 是否与前一次事件驱动的推送重复
// (同源 + 同摘要),因此重复的设备/提醒事件
// 不会刷屏主人(防刷)。当 PushGate 对事件本身被绕过时,
// 它是仅剩的一道仍适用于事件驱动推送的
// 门(主人要求:"该发就发"——真正的事件照发,只扣下真正的重复)。
//
// It atomically records tr as the latest event key when the push proceeds, and
// clears the key on any non-event push (heartbeat) so a later repeat of a prior
// event is not spuriously suppressed by an intervening reflection.
// 推送进行时它原子地把 tr 记为最新事件键,并在任何非事件推送
// (心跳)时清除该键,以免更早事件的再次重复
// 被中间一次反思错误地抑制。
//
// The caller runs it before handing the push to the Pusher; a true duplicate is
// recorded as suppressed (RouteNone) rather than delivered.
// 调用方把它跑在把推送交给 Pusher 之前;真正的重复
// 会被记为已抑制(RouteNone),而不是投递出去。
func (b *Backthink) eventRedundant(tr Trigger) bool {
if tr.Kind != TriggerEvent {
// A non-event push (heartbeat) breaks the same-event chain.
// 非事件推送(心跳)会打断同一事件链。
b.eventMu.Lock()
b.lastEventKey = ""
b.eventMu.Unlock()
+11 -11
View File
@@ -13,8 +13,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
)
// mockThinker records every Think invocation and returns a fixed conclusion. It
// respects ctx cancellation so Stop can drain a hung pass.
// mockThinker 记录每次 Think 调用并返回固定结论。它
// 尊重 ctx 取消,好让 Stop 能排空挂住的轮次。
type mockThinker struct {
mu sync.Mutex
text string
@@ -45,12 +45,12 @@ func (m *mockThinker) Think(ctx context.Context, sessionID, trigger string) (str
func (m *mockThinker) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
func (m *mockThinker) lastTrigger() string { m.mu.Lock(); defer m.mu.Unlock(); return m.last }
// mockState returns a fixed UserState.
// mockState 返回固定的 UserState。
type mockState struct{ state UserState }
func (m *mockState) Current(context.Context) (UserState, error) { return m.state, nil }
// mockPusher records Push calls and returns a fixed result.
// mockPusher 记录 Push 调用并返回固定结果。
type mockPusher struct {
mu sync.Mutex
result PushResult
@@ -70,7 +70,7 @@ func (p *mockPusher) Push(ctx context.Context, req PushRequest) PushResult {
func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls }
// mockActive implements ActiveClient.
// mockActive 实现 ActiveClient。
type mockActive struct {
mu sync.Mutex
n int
@@ -87,7 +87,7 @@ func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessa
return m.n, m.err
}
// mockIMAdapter implements IMAdapter.
// mockIMAdapter 实现 IMAdapter。
type mockIMAdapter struct {
mu sync.Mutex
err error
@@ -105,14 +105,14 @@ func (m *mockIMAdapter) SendProactive(ctx context.Context, msg adapter.UnifiedMe
func (m *mockIMAdapter) count() int { m.mu.Lock(); defer m.mu.Unlock(); return m.calls }
// mockGate implements PushGate.
// mockGate 实现 PushGate。
type mockGate struct{ allow bool }
func (g *mockGate) Allow(context.Context, PushKind) bool { return g.allow }
func (g *mockGate) RecordPush(PushKind) {}
// newTestDB opens an isolated in-memory SQLite handle for one test and closes it
// on cleanup.
// newTestDB 为单个测试打开一个隔离的内存 SQLite 句柄,并在
// 清理时关闭它。
func newTestDB(t *testing.T) *sql.DB {
t.Helper()
h, err := db.Open(context.Background(), ":memory:", nil)
@@ -123,12 +123,12 @@ func newTestDB(t *testing.T) *sql.DB {
return h
}
// discardLog returns a logger that writes nowhere.
// discardLog 返回一个什么都不写的日志器。
func discardLog() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// eventually polls cond until it is true or the deadline passes.
// eventually 轮询 cond 直到它为真或超过期限。
func eventually(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
+57 -57
View File
@@ -6,63 +6,63 @@ import (
"unicode"
)
// The autonomous think is guided (guidancePrompt in context.go) to conclude with
// a compact, machine-parseable decision so the service no longer has to infer
// whether to push from the trigger's importance alone (design/20 §2 P2):
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
// 机器可解析的决策收尾,好让服务不必再从触发的重要度
// 单独推断是否推送(design/20 §2 P2):
//
// [我决定:push] (or no_push)
// [我决定:push] (或 no_push)
// [渠道:web|im|kook|telegram|...|auto]
// <the warm message body>
// <温暖的正文内容>
//
// The markers are parsed as plain text so a model that formats them slightly
// differently still yields a usable decision; a malformed/ambiguous decision
// marker fails safe to "no push".
// 这些标记按纯文本解析,因此模型稍有不同地
// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策
// 标记失败安全为「不推送」。
var (
// decideMarker matches the push/no_push decision token, tolerating a colon in
// either width and optional spaces. It is used to recover the decision, so it
// stays strict on the bracketed form: a bare "我决定:push" is treated as prose,
// not as a decision (fail safe).
// decideMarker 匹配 push/no_push 决策词,容忍两种宽度的
// 冒号与可选空格。它用于恢复决策,所以对
// 带括号的形式保持严格:裸的 "我决定:push" 被当作散文,
// 而不是决策(失败安全)。
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
// channelMarker matches the target-channel token on the decision line.
// channelMarker 匹配决策行上的目标渠道词。
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
// decidePrefixes are the marker shapes that signal a decision was attempted
// but could not be parsed, so the pass is treated as ambiguous (fail safe). It
// is deliberately the bracketed form only, so a message that merely contains
// "我决定:" in prose (common in Chinese) is not misread as a malformed marker.
// decidePrefixes 是「尝试了决策但无法解析」的标记形状,
// 这类轮次被视为含糊(失败安全)。它
// 刻意只含带括号形式,因此仅仅在散文中出现
// "我决定:"(中文里很常见)的消息不会被误读为格式错乱的标记。
decidePrefixes = []string{"[我决定"}
// stripDecideRe / stripChannelRe recognize the decision/channel markers as a
// token to delete from the user-facing body, in their intended bracketed form
// and with full-width brackets, so a well-formatted decision never leaks.
// stripDecideRe / stripChannelRe 把决策/渠道标记识别为
// 需要从用户可见正文中删除的词,按它们预期的带括号形式
// 与全角括号形式处理,因此格式良好的决策永不泄漏。
stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`)
stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`)
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
)
// nonePhrases are conclusions that mean "nothing to report" when no marker is
// present. The body is normalized (case-folded, punctuation/spaces stripped)
// before comparison so "无", "无。" and "Nothing to report." all collapse to the
// same token.
// nonePhrases 是没有标记时表示「无可汇报」的结论。
// 正文在比较前先归一化(折叠大小写、去掉标点/空格),
// 因此 "无"、"无。" 与 "Nothing to report." 都收敛到
// 同一个词。
var nonePhrases = []string{
"无", "没什么", "没有", "无物可报", "暂无", "没有值得说的",
"nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew",
}
// parseProactive recovers the push decision, the target-channel hint and the
// clean message body from a think conclusion. It is a pure function: it reads no
// state and performs no I/O, so it is safe to call concurrently and cheap to test.
// parseProactive 从思考结论中恢复推送决策、目标渠道提示与
// 干净的正文消息。它是纯函数:不读状态、不做 I/O,
// 因此可安全并发调用、测试成本低。
//
// Semantics (design/20 §2 P2):
// - A well-formed [我决定:push|no_push] marker is authoritative.
// - A malformed/ambiguous decision marker → no push (fail safe).
// - No marker: a body that reads as "无"/"nothing to report" → no push;
// otherwise the body is treated as a proactive message (push=true), matching
// the pre-P2 default.
// - The channel hint is the [渠道:...] token, lowercased; empty/auto keeps the
// default active-client-first, IM-fallback route.
// - A push decision whose body is empty or still reads as "nothing" → no push
// (fail safe), so a lone marker never pushes an empty message.
// 语义(design/20 §2 P2):
// - 格式良好的 [我决定:push|no_push] 标记具有权威性。
// - 格式错乱/含糊的决策标记 → 不推送(失败安全)。
// - 无标记:正文读作 "无"/"nothing to report" → 不推送;
// 否则正文被当作主动消息(push=true),与
// P2 之前的默认一致。
// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持
// 默认的活跃客户端优先、IM 降级路由。
// - 决策为推送但正文为空或仍读作「无」 → 不推送
// (失败安全),因此单独一个标记绝不会推出空消息。
func parseProactive(conclusion string) (message, channelHint string, push bool) {
text := strings.TrimSpace(conclusion)
@@ -83,8 +83,8 @@ func parseProactive(conclusion string) (message, channelHint string, push bool)
return message, channelHint, push
}
// matchDecide returns the matched decision token (lowercased) and whether a
// well-formed decision marker was present.
// matchDecide 返回匹配到的决策词(已转小写)以及
// 是否存在格式良好的决策标记。
func matchDecide(text string) (string, bool) {
m := decideMarker.FindStringSubmatch(text)
if m == nil {
@@ -93,8 +93,8 @@ func matchDecide(text string) (string, bool) {
return strings.ToLower(m[1]), true
}
// hasAmbiguousDecide reports whether a decision marker was attempted but could
// not be parsed, which the caller treats as "do not push" (fail safe).
// hasAmbiguousDecide 报告是否尝试了决策标记但
// 无法解析,调用方把它当作「不推送」(失败安全)。
func hasAmbiguousDecide(text string) bool {
if decideMarker.MatchString(text) {
return false
@@ -107,9 +107,9 @@ func hasAmbiguousDecide(text string) bool {
return false
}
// matchChannel returns the lowercased target-channel token, or "" when the
// conclusion carries no (or a malformed) channel marker. A malformed channel
// marker degrades to the default route rather than failing the whole pass.
// matchChannel 返回已转小写的目标渠道词;结论未携带
// (或携带格式错乱的)渠道标记时返回 ""。格式错乱的渠道
// 标记降级为默认路由,而不是让整轮失败。
func matchChannel(text string) string {
m := channelMarker.FindStringSubmatch(text)
if m == nil {
@@ -118,15 +118,15 @@ func matchChannel(text string) string {
return strings.ToLower(m[1])
}
// stripInternalMarkers removes the structured decision/channel markers so a
// proactive message body never carries them to the user. It handles the intended
// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit —
// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then
// drops any line that becomes empty and collapses the remainder. It is applied
// both inside parseProactive (so the message and record are clean) and again as a
// final guard in buildProactiveMessage, so a marker cannot survive into the
// outbound body no matter how it was formatted. Only our logs/records may carry
// the marker; the user-facing message must be pure warm content.
// stripInternalMarkers 移除结构化的决策/渠道标记,因此
// 主动消息正文绝不会把它们带给用户。它处理预期的
// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后
// 丢弃因此变空的行并压缩剩余部分。它既在
// parseProactive 内部应用(使消息与记录干净),又作为
// buildProactiveMessage 里的最后一道守护,因此无论标记如何
// 被格式化,都无法存活到出站正文里。只有我们的日志/记录可以携带
// 该标记;面向用户的消息必须是纯温暖内容。
func stripInternalMarkers(text string) string {
lines := strings.Split(text, "\n")
out := make([]string, 0, len(lines))
@@ -144,8 +144,8 @@ func stripInternalMarkers(text string) string {
return strings.Join(out, "\n")
}
// looksNone reports whether the body says "nothing worth saying". It is used both
// to drive the no-marker default and to fail-safe a contradictory push marker.
// looksNone 报告正文是否表示「没什么值得说的」。它既用于
// 驱动无标记时的默认,也用于对矛盾的 push 标记失败安全。
func looksNone(text string) bool {
t := normalizeNone(text)
if t == "" {
@@ -159,8 +159,8 @@ func looksNone(text string) bool {
return false
}
// normalizeNone lowercases the body and strips spaces and punctuation so the
// nonePhrases compare cleanly across "无", "无。", "Nothing to report." and similar.
// normalizeNone 把正文转小写并去掉空格与标点,好让
// nonePhrases 在 "无"、"无。"、"Nothing to report." 之类之间干净比较。
func normalizeNone(text string) string {
var b strings.Builder
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
+18 -18
View File
@@ -41,7 +41,7 @@ func TestParseProactive_NoPushMarker(t *testing.T) {
func TestParseProactive_ChannelColonWidth(t *testing.T) {
t.Parallel()
// Tolerate a full-width colon and trailing spaces, and uppercase channel value.
// 容忍全角冒号与尾随空格,以及大写渠道值。
conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡"
msg, hint, push := parseProactive(conclusion)
if !push {
@@ -104,11 +104,11 @@ func TestParseProactive_MalformedFailsSafe(t *testing.T) {
func TestParseProactive_MarkerWithoutBody(t *testing.T) {
t.Parallel()
// A lone push marker with no body must not push an empty message (fail safe).
// 单独一个 push 标记、正文为空时绝不能推出空消息(失败安全)。
if _, _, push := parseProactive("[我决定:push]"); push {
t.Fatalf("push = true for a marker with no body, want false")
}
// A push marker whose body says "nothing" is contradictory → fail safe.
// push 标记的正文写着「无」是自相矛盾的 → 失败安全。
if _, _, push := parseProactive("[我决定:push]\n无"); push {
t.Fatalf("push = true for a push marker with nothing body, want false")
}
@@ -131,8 +131,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
b.think(context.Background(), heartbeatTrigger())
// The channel hint "kook" routes to the IM bridge (a specific platform), not the
// active client.
// 渠道提示 "kook" 路由到 IM 桥(一个具体平台),而不是
// 活跃客户端。
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls)
}
@@ -172,8 +172,8 @@ func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) {
t.Parallel()
// No channel marker → auto → a single IM channel (the bridge picks her choice /
// her recent platform / the whitelist default), NOT the active web client.
// 无渠道标记 → auto → 单个 IM 渠道(由桥挑出她的选择 /
// 她最近的平台 / 白名单默认),而不是活跃 Web 客户端。
conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了"
thinker := &mockThinker{text: conclusion}
active := &mockActive{n: 2}
@@ -241,8 +241,8 @@ func TestThink_ConcludedNoPush_NotPushed(t *testing.T) {
func TestThink_DoNotDisturbBlocksPush(t *testing.T) {
t.Parallel()
// She wants to push, but the user is asleep → the hard safety gate refuses,
// regardless of her decision.
// 她想推送,但用户正在睡觉 → 硬安全门拒绝,
// 与她的决策无关。
conclusion := "[我决定:push]\n想跟你说晚安"
thinker := &mockThinker{text: conclusion}
b, _ := newBackthink(t, Config{}, Deps{
@@ -292,9 +292,9 @@ func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) {
func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
t.Parallel()
// When a specific platform is requested but no IM sink is wired, the pusher
// falls back to the default route so the owner still receives the message, and
// records the requested hint.
// 请求了具体平台但未接线 IM 槽时,推送器
// 降级到默认路由,好让主人仍能收到消息,并
// 记录下请求的提示。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{
@@ -309,9 +309,9 @@ func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
}
}
// TestStripInternalMarkers_CleanBody verifies the internal decision/channel
// markers are removed from a proactive body in every shape a model may emit
// (bracketed, full-width, bare), so a marker can never reach the user-facing text.
// TestStripInternalMarkers_CleanBody 验证内部决策/渠道
// 标记在模型可能发出的每种形状下(带括号、全角、裸写)
// 都被从主动正文中移除,因此标记永远无法到达面向用户的文本。
func TestStripInternalMarkers_CleanBody(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -336,9 +336,9 @@ func TestStripInternalMarkers_CleanBody(t *testing.T) {
}
}
// TestThink_BareMarkersStrippedFromBody is the regression for the KOOK leak: a
// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must
// still deliver a clean warm body, never the structured decision text.
// TestThink_BareMarkersStrippedFromBody 是 KOOK 泄漏的回归用例:
// 携带裸写(无括号)"我决定:push"/"渠道:..." 标记的结论仍须
// 投递干净的温暖正文,绝不是结构化的决策文本。
func TestThink_BareMarkersStrippedFromBody(t *testing.T) {
t.Parallel()
conclusion := "我决定:push\n渠道:kook\n记得喝水哦"
+129 -129
View File
@@ -12,56 +12,56 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// Route identifies how a proactive message was actually delivered, for the
// monitor panel and the push result record.
// Route 标识一条主动消息实际是如何投递的,供监控面板
// 与推送结果记录使用。
type Route string
const (
// RouteNone means no push was attempted or no target was available.
// RouteNone 表示未尝试推送,或无可用目标。
RouteNone Route = "none"
// RouteRateLimited means the push was refused by the rate limiter.
// RouteRateLimited 表示推送被限流器拒绝。
RouteRateLimited Route = "rate_limited"
// RouteActiveClient means the message went to an active web/native client.
// RouteActiveClient 表示消息发往了活跃 Web/原生客户端。
RouteActiveClient Route = "active_client"
// RouteIMBridge means the message fell back to the IM bridge.
// RouteIMBridge 表示消息降级到 IM 桥。
RouteIMBridge Route = "im_bridge"
)
// Channel-hint tokens recognized by the push router. They come from the
// conclusion's [渠道:...] marker (parseProactive) and select where a proactive
// message is delivered. An empty/unknown hint is treated as auto. A concrete
// platform id (kook/telegram/...) is a value other than these three.
// 推送路由器识别的渠道提示词。它们来自结论的
// [渠道:...] 标记(parseProactive),用于选择主动消息
// 的投递去向。为空/未知的提示按 auto 处理。具体的
// 平台 id(kook/telegram/...)是这三个之外的值。
const (
channelAuto = "auto"
channelIM = "im"
channelWeb = "web"
// defaultProactivePlatform is the Platform the outbound proactive message
// carries when the conclusion names no specific channel.
// defaultProactivePlatform 是结论未指明具体渠道时,
// 出站主动消息携带的 Platform。
defaultProactivePlatform = "cyrene-backthink"
)
// String returns the route as a plain label.
// String 把路由作为普通标签返回。
func (r Route) String() string { return string(r) }
// PushResult is the outcome of one proactive push attempt, recorded with each
// thinking pass (design/12 §4).
// PushResult 是一次主动推送尝试的结果,随每轮思考
// 一起记录(design/12 §4)。
type PushResult struct {
// Pushed reports whether the message was delivered.
// Pushed 报告消息是否已投递。
Pushed bool
// Route is the delivery route taken.
// Route 是实际采用的路由。
Route Route
// Bypassed reports whether this push was event-driven and skipped the PushGate
// (a real event, "该发就发"). A heartbeat push is gate-governed and never
// bypassed.
// Bypassed 报告本次推送是否为事件驱动、跳过了 PushGate
// (真正的事件,"该发就发")。心跳推送受门控管辖,绝不
// 被绕过。
Bypassed bool
// Detail is a short human-readable note (e.g. client count, decision reason).
// Detail 是简短可读的备注(如客户端数量、决策理由)。
Detail string
// Err is the underlying error text when delivery failed, or that of the
// provider that decided not to push.
// Err 是投递失败时的底层错误文本,或决定不推送的
// 提供者的错误文本。
Err string
}
// String returns a concise summary for logs.
// String 返回用于日志的简洁摘要。
func (p PushResult) String() string {
if p.Err != "" {
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q err=%q", p.Pushed, p.Route, p.Bypassed, p.Detail, p.Err)
@@ -69,110 +69,110 @@ func (p PushResult) String() string {
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
}
// ActiveClient delivers a proactive message to every currently active web/native
// client and reports how many received it. It is the preferred push target. The
// gateway WebSocket hub implements this in production; tests inject a mock.
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
// 客户端,并报告有多少个收到。它是首选的推送目标。
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
type ActiveClient interface {
// SendProactive pushes msg to each active client and returns the number of
// clients that accepted it.
// SendProactive 把 msg 推给每个活跃客户端,并返回
// 接受它的客户端数量。
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
}
// IMAdapter is the IM bridge fallback: it pushes a proactive message over an IM
// channel. Every adapter.PlatformAdapter satisfies this subset, so the service
// depends on the narrow interface rather than the concrete adapter type.
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
// 渠道推送。每个 adapter.PlatformAdapter 都满足这个子集,因此本服务
// 依赖这个窄接口而非具体适配器类型。
type IMAdapter interface {
// SendProactive pushes msg to the owner's IM channel.
// SendProactive 把 msg 推给主人的 IM 渠道。
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error
}
// PushGate decides whether a proactive push may proceed right now, applying the
// rate limits of design/11 §6 plus a per-flavor limit and a post-push cooldown. It
// is a seam over channel/session's ProactivePolicy/RateLimiter so the service can
// be tested with a mock.
// PushGate 判断一次主动推送此刻能否进行,套用
// design/11 §6 的限频规则,外加按风格限制与推送后冷却。它
// 是对 channel/session 的 ProactivePolicy/RateLimiter 的接缝,因此本服务
// 可以用 mock 测试。
type PushGate interface {
// Allow reports whether a proactive push of the given kind may be sent now.
// Allow 报告给定风格的主动推送此刻能否发送。
Allow(ctx context.Context, kind PushKind) bool
// RecordPush notes a successful delivery so the cooldown/per-kind windows
// start from a real successful push, not a merely-attempted one.
// RecordPush 记下一次成功投递,使冷却/按风格窗口
// 从一次真正的成功推送起算,而不是仅仅尝试过。
RecordPush(kind PushKind)
}
// PushRequest carries one proactive push attempt: the outbound message plus the
// channel target the autonomous conclusion asked for (design/20 §2 P2).
// ChannelHint is the lowercased [渠道:...] token: ""/auto pick the default (the
// owner mandate: ONE channel, chosen by her / her most recent platform / the
// whitelist default); "web" targets the active client; "im" targets the IM bridge;
// a concrete platform id (kook/telegram/weixin-ilink/...) targets the IM bridge
// with the message tagged for that platform.
// PushRequest 携带一次主动推送尝试:出站消息加上
// 自主结论所要求的目标渠道(design/20 §2 P2)。
// ChannelHint 是已转小写的 [渠道:...] 词:""/auto 选默认(
// 主人要求:只发一个渠道,由她 / 她最近的平台 /
// 白名单默认决定);"web" 指向活跃客户端;"im" 指向 IM 桥;
// 具体平台 id(kook/telegram/weixin-ilink/...)指向 IM 桥
// 并把消息标记为该平台。
type PushRequest struct {
// Message is the normalized outbound proactive message.
// Message 是归一化后的出站主动消息。
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
// ChannelHint 是请求的目标渠道,她未选择时为 ""。
ChannelHint string
// TriggerKind records what woke this pass. An event-driven push bypasses the
// PushGate (owner mandate: "该发就发"); a heartbeat stays gate-governed.
// TriggerKind 记录唤醒本轮的原因。事件驱动的推送绕过
// PushGate(主人要求:"该发就发");心跳保持受门控管辖。
TriggerKind TriggerKind
}
// Pusher delivers a proactive message to ONE target channel. It is bounded by the
// rate gate and honors the channel hint the autonomous conclusion selected: "web"
// goes to the active client, everything else goes over the IM bridge a single time,
// where the bridge picks the one channel (her choice → her most recent platform →
// whitelist default, WeChat included).
// Pusher 把主动消息投递到一个目标渠道。它受
// 频率门约束,并尊重自主结论所选的渠道提示:"web"
// 去活跃客户端,其余都经 IM 桥走一次,
// 由桥挑出那一个渠道(她的选择 → 她最近的平台 →
// 白名单默认,微信含在内)。
type Pusher interface {
// Push delivers req to a single target channel, per the channel hint. It never
// blocks on a slow target.
// Push 按渠道提示把 req 投递到单个目标渠道。它绝不
// 阻塞在慢目标上。
Push(ctx context.Context, req PushRequest) PushResult
}
// pusher is the concrete Pusher: it consults the gate, then routes the message to
// exactly one target — the active client for a "web" hint, otherwise the IM bridge.
// After the local delivery it mirrors the message to the optional push outlet
// (self-hosted service → Getui fallback) without blocking that delivery.
// pusher 是具体的 Pusher:它先咨询频率门,再把消息路由到
// 恰好一个目标——"web" 提示走活跃客户端,否则走 IM 桥。
// 本地投递之后,它把消息镜像到可选的推送出口
// (自托管服务 → 个推降级),且不阻塞该投递。
type pusher struct {
active ActiveClient
im IMAdapter
gate PushGate
exporter PushExporter
log *slog.Logger
// exportDone, when non-nil, receives one value after each asynchronous export
// completes. It exists so tests can deterministically observe the hook; the
// send is non-blocking, so a full channel never stalls the exporter.
// exportDone 非 nil 时,会在每次异步导出完成后收到一个值。
// 它存在是为了让测试能确定性地观测该钩子;发送是非阻塞的,
// 所以通道满也不会卡住导出器。
exportDone chan struct{}
}
// newPusher builds a Pusher from the optional active-client sink, IM sink and
// rate gate. Any of them may be nil; a nil target simply makes that route
// unavailable. The push outlet is attached separately with withExporter.
// newPusher 由可选的活跃客户端槽、IM 槽与
// 频率门构建 Pusher。它们都可以为 nil;目标为 nil 只是让那条路由
// 不可用。推送出口用 withExporter 单独挂上。
func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logger) *pusher {
return &pusher{active: active, im: im, gate: gate, log: log}
}
// withExporter attaches the push outlet hook and returns the pusher for fluent
// use. A nil exporter leaves the hook disabled.
// withExporter 挂上推送出口钩子并返回 pusher 以便链式
// 调用。exporter 为 nil 则钩子保持关闭。
func (p *pusher) withExporter(exporter PushExporter) *pusher {
p.exporter = exporter
return p
}
// Push implements Pusher: it applies the rate gate, then routes the message to a
// single target. A "web" hint reaches the active web/native client; every other
// hint ("im", "auto", or a concrete platform) goes over the IM bridge once, where
// the bridge selects one owner channel (her [渠道:...] choice, then the platform she
// used most recently, then the whitelist default). The IM bridge is primary, so a
// push never fans out to several channels. When no IM sink is wired the active
// client is the honest fallback, so the owner is still reached rather than the
// message being dropped.
// Push 实现 Pusher:它套用频率门,然后把消息路由到
// 单个目标。"web" 提示到达活跃 Web/原生客户端;其它任何
// 提示("im"、"auto" 或具体平台)经 IM 桥走一次,由
// 桥挑出一个主人渠道(她的 [渠道:...] 选择,其次是她
// 最近用过的平台,再次是白名单默认)。IM 桥是主路径,所以一次
// 推送绝不扇出到多个渠道。未接线 IM 槽时,活跃
// 客户端就是诚实的降级,因此主人仍能被触达,而不是
// 消息被丢弃。
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if err := ctx.Err(); err != nil {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
kind := classifyPush(req.Message.Content)
// A real event-driven push bypasses the PushGate: it should go out as-is ("该发就发"),
// independent of the heartbeat throttle (60min≤1 / cooldown / per-flavor window).
// Only the periodic heartbeat stays gate-governed, so the owner is never spammed
// by an empty reflection. Same-event dedup is applied upstream in think().
// 真正事件驱动的推送绕过 PushGate:它应原样发出("该发就发"),
// 不受心跳限流约束(60min≤1 / 冷却 / 按风格窗口)。
// 只有周期性心跳保持受门控管辖,主人因此绝不会被
// 空洞的反思刷屏。同一事件去重在 think() 上游套用。
eventDriven := req.TriggerKind == TriggerEvent
if p.gate != nil && !eventDriven && !p.gate.Allow(ctx, kind) {
p.log.Warn("backthink push rate limited", "kind", kind.String())
@@ -186,10 +186,10 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if hint == channelWeb {
res = p.pushActive(ctx, msg)
} else {
// "im"/"auto"/a concrete platform all travel over the IM bridge once. A
// generic hint hands the sentinel platform so the bridge falls through to its
// recent-active / default selection; a concrete hint is honored when the
// channel is whitelisted and connected.
// "im"/"auto"/具体平台都经 IM 桥走一次。
// 泛化提示交出哨兵平台,让桥落到它的
// 最近活跃 / 默认选择上;具体提示在渠道
// 已加入白名单且已连接时被尊重。
if hint == channelIM || hint == channelAuto {
msg.Platform = defaultProactivePlatform
} else {
@@ -200,16 +200,16 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
res = p.pushActive(ctx, msg)
}
}
// Only a real delivery advances the cooldown/per-kind windows (and only for a
// gate-governed push), so an attempted-but-failed push does not suppress a later
// genuine one, and an event push never contributes to the heartbeat throttle.
// 只有真正的投递才推进冷却/按风格窗口(且只对
// 受门控的推送),因此尝试过但失败的推送不会抑制之后的
// 真实推送,而事件推送也绝不计入心跳限流。
if res.Pushed && p.gate != nil && !eventDriven {
p.gate.RecordPush(kind)
}
// D1: mirror the proactive message to the external push outlet (self-hosted
// unified push service → Getui fallback). Fire-and-forget: a slow or failing
// outlet never delays or breaks the local delivery above, and a rate-limited
// push returned before reaching here.
// D1:把主动消息镜像到外部推送出口(自托管
// 统一推送服务 → 个推降级)。发后不管:慢或失败的
// 出口绝不延迟或破坏上面的本地投递,而且限流被拒的
// 推送在此之前就已返回。
p.exportAsync(msg)
res = annotateHint(res, req.ChannelHint)
if eventDriven {
@@ -219,9 +219,9 @@ func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
return res
}
// exportAsync reports msg to the push outlet on a detached, bounded goroutine so
// the proactive path never blocks on the outlet. A nil exporter (hook disabled)
// or a nil outlet result is a no-op; a delivery miss or error is only logged.
// exportAsync 在脱离的、有界的 goroutine 上把 msg 上报给推送出口,
// 因此主动路径绝不在出口上阻塞。exporter 为 nil(钩子关闭)
// 或出口结果为 nil 都是空操作;投递未命中或出错只记日志。
func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
if p.exporter == nil {
return
@@ -246,7 +246,7 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
}()
}
// pushActive delivers only to the active web/native client.
// pushActive 只投递给活跃 Web/原生客户端。
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {
n, err := p.active.SendProactive(ctx, msg)
@@ -259,7 +259,7 @@ func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) Pus
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
}
// pushIM delivers only to the IM bridge.
// pushIM 只投递给 IM 桥。
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
@@ -271,9 +271,9 @@ func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushRes
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
}
// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to
// the canonical route tokens (auto/im/web); anything else is a concrete platform
// id (kook/telegram/...). Unknown/blank maps to auto.
// normalizeChannelHint 把渠道提示转小写并去空白,把同义词映射到
// 规范路由词(auto/im/web);其它一切视为具体平台
// id(kook/telegram/...)。未知/空映射为 auto。
func normalizeChannelHint(hint string) string {
switch strings.ToLower(strings.TrimSpace(hint)) {
case "", "auto":
@@ -287,15 +287,15 @@ func normalizeChannelHint(hint string) string {
}
}
// isSpecificChannel reports whether hint names a concrete platform rather than a
// routing keyword (auto/im/web).
// isSpecificChannel 报告 hint 指的是具体平台,而不是
// 路由关键词(auto/im/web)。
func isSpecificChannel(hint string) bool {
return hint != channelAuto && hint != channelIM && hint != channelWeb
}
// annotateHint appends the requested channel hint to the push result detail so
// the record/panel surfaces which channel she chose, even when the route fell
// back. Blank/auto hints add nothing.
// annotateHint 把请求的渠道提示附加到推送结果 detail 上,
// 好让记录/面板显示她选了哪个渠道,即便路由发生
// 了降级。空白/auto 提示不加任何内容。
func annotateHint(res PushResult, hint string) PushResult {
h := strings.ToLower(strings.TrimSpace(hint))
if h == "" || h == channelAuto {
@@ -310,8 +310,8 @@ func annotateHint(res PushResult, hint string) PushResult {
return res
}
// annotateBypass tags an event-driven push result so the record/panel shows it
// skipped the PushGate, distinct from a heartbeat push that was rate-limited.
// annotateBypass 给事件驱动的推送结果打标记,使记录/面板显示它
// 跳过了 PushGate,与因限流被拒的心跳推送区分开。
func annotateBypass(res PushResult) PushResult {
if res.Detail == "" {
res.Detail = "event_bypass"
@@ -321,10 +321,10 @@ func annotateBypass(res PushResult) PushResult {
return res
}
// rateGate is the production PushGate: a sliding-window counter over
// session.RateLimiter (design/11 §6 anti-spam rule) combined with a post-push
// cooldown and a per-flavor (问候/提醒/分享/关心) window so the owner is never
// spammed with the same style (design/12 §6 策略二).
// rateGate 是生产用的 PushGate:一个基于
// session.RateLimiter 的滑动窗口计数器(design/11 §6 反骚扰规则),
// 结合推送后冷却与按风格(问候/提醒/分享/关心)窗口,
// 使主人绝不会被同一风格刷屏(design/12 §6 策略二)。
type rateGate struct {
log *slog.Logger
limiter *session.RateLimiter
@@ -337,10 +337,10 @@ type rateGate struct {
lastKind map[PushKind]time.Time
}
// newRateGate builds a gate that allows at most limit pushes within window, keeps
// pushes cooldown apart after a success, and limits each PushKind to one push per
// kindWindow. A non-positive limit or window yields an always-allow limiter (no
// rate rule); a non-positive cooldown or kindWindow disables that specific guard.
// newRateGate 构建一个门:在 window 内最多放行 limit 次推送,
// 成功之后推送之间至少间隔 cooldown,且每种 PushKind 在
// kindWindow 内只允许一次。非正的 limit 或 window 得到始终放行的
// 限流器(无限频规则);非正的 cooldown 或 kindWindow 会禁用对应的那道守护。
func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slog.Logger) *rateGate {
return &rateGate{
log: log,
@@ -352,9 +352,9 @@ func newRateGate(limit int, window, cooldown, kindWindow time.Duration, log *slo
}
}
// Allow implements PushGate. A canceled context denies (fail closed). It refuses
// when the last successful push is inside the cooldown, or when the same flavor
// was delivered within kindWindow, before consulting the sliding-window limiter.
// Allow 实现 PushGate。已取消的 context 一律拒绝(fail closed)。
// 当最近一次成功推送仍在冷却期内,或同一风格
// 在 kindWindow 内已投递过,它就在咨询滑动窗口限流器之前拒绝。
func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
if err := ctx.Err(); err != nil {
return false
@@ -371,8 +371,8 @@ func (g *rateGate) Allow(ctx context.Context, kind PushKind) bool {
return g.limiter.Allow()
}
// RecordPush implements PushGate: it stamps the last successful push time and the
// per-flavor last time so the cooldown and kind window start from a real delivery.
// RecordPush 实现 PushGate:它记下最近成功推送时间与
// 按风格的最近时间,使冷却与风格窗口从真实投递起算。
func (g *rateGate) RecordPush(kind PushKind) {
now := g.now()
g.mu.Lock()
@@ -381,15 +381,15 @@ func (g *rateGate) RecordPush(kind PushKind) {
g.lastKind[kind] = now
}
// buildProactiveMessage assembles the normalized outbound message carrying a
// think conclusion's clean body. It tags the platform with the requested channel
// when the conclusion names a concrete platform, otherwise the default backthink
// platform. The concrete active-client and IM adapters apply their own
// routing/targeting to the owner (red line: owner only).
// buildProactiveMessage 拼装承载思考结论文本的、
// 归一化的出站消息。当结论指明具体平台时,它用请求的渠道
// 给平台打标签,否则用 backthink 的默认
// 平台。具体的活跃客户端与 IM 适配器各自对主人施加
// 自己的路由/寻址(红线:只针对主人)。
//
// The body is run through stripInternalMarkers as a final guard so a structured
// decision/channel marker that survived parsing can never reach the user-facing
// message (only our logs/records may carry it).
// 正文会再过一遍 stripInternalMarkers 作为最后一道守护,因此
// 侥幸存活过解析的结构化决策/渠道标记绝不
// 能到达面向用户的消息(只有我们的日志/记录可以携带它)。
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
platform := defaultProactivePlatform
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
+56 -56
View File
@@ -15,58 +15,58 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// The push outlet (D1, design/12 §4/§6): proactive messages are mirrored to an
// external push service so the owner's mobile client is reached even when no
// WebSocket client or IM channel is live. Delivery is two-tier by design:
// 推送出口(D1,design/12 §4/§6):主动消息被镜像到
// 外部推送服务,使主人的移动端即便在没有
// WebSocket 客户端或 IM 渠道在线时也能被触达。按设计投递分两级:
//
// 1. the owner's self-hosted unified push service (primary; PUSH_EXPORTER_URL),
// 2. Getui (个推) as the fallback for when the self-hosted service is down.
// 1. 主人的自托管统一推送服务(主级;PUSH_EXPORTER_URL),
// 2. 个推(Getui)作为自托管服务不可用时的降级。
//
// Tier 2 has no SDK integration yet, so an unavailable primary logs an explicit
// "Getui fallback not integrated" note and reports not-delivered instead of
// pretending the message went out. The hook is fire-and-forget: it never delays
// or breaks the existing active-client / IM delivery.
// 第 2 级尚未接入 SDK,因此主级不可用时只记一条明确的
// "Getui fallback not integrated" 备注,并上报未投递,而不
// 假装消息已发出。该钩子是发后不管:它绝不延迟
// 或破坏既有的活跃客户端 / IM 投递。
// defaultExportTimeout bounds one export attempt so a hung endpoint cannot pin a
// goroutine forever.
// defaultExportTimeout 约束单次导出尝试,因此挂死的端点无法永久
// 占住一个 goroutine。
const defaultExportTimeout = 5 * time.Second
// maxExportResponseBytes caps how much of the outlet's response body is drained
// before the connection is reused, so a hostile/buggy endpoint cannot stream an
// unbounded body into memory.
// maxExportResponseBytes 限制连接复用前从出口响应体里
// 抽掉多少数据,因此恶意或有缺陷的端点无法把一个
// 无界的响应体流进内存。
const maxExportResponseBytes = 4 << 10
// PushExporter reports one proactive message to an external push outlet. Export
// must be safe for concurrent use and must honor ctx. A nil error with
// delivered=false means the outlet could not deliver (for example the fallback
// tier is not wired); the caller treats that as a logged miss, never a failure
// of the local push path.
// PushExporter 把一条主动消息上报给外部推送出口。Export
// 必须并发安全且必须尊重 ctx。nil 错误配上
// delivered=false 表示出口无法投递(例如降级
// 级尚未接线);调用方把它当作一次记账的未命中,绝不当作本地
// 推送路径的失败。
type PushExporter interface {
Export(ctx context.Context, msg adapter.UnifiedMessage) (delivered bool, err error)
}
// PushOutletConfig carries the push-outlet wiring. Enabled=false disables the
// hook entirely (NewPushOutlet returns nil). URL/Token/Timeout describe the
// self-hosted primary service; the Getui* fields are reserved for the deferred
// fallback and are not consumed yet.
// PushOutletConfig 携带推送出口的接线。Enabled=false 完全关闭
// 该钩子(NewPushOutlet 返回 nil)。URL/Token/Timeout 描述
// 自托管主服务;Getui* 字段保留给延后的
// 降级级,目前尚未消费。
type PushOutletConfig struct {
// Enabled turns the outlet hook on.
// Enabled 打开出口钩子。
Enabled bool
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
// Empty leaves the primary tier unconfigured.
// URL 是自托管统一推送服务端点(HTTP POST JSON)。
// 为空则主级保持未配置。
URL string
// Token is sent as "Authorization: Bearer <token>". It is never logged.
// Token 以 "Authorization: Bearer <token>" 发送。它绝不写日志。
Token string
// Timeout bounds one export attempt. Zero uses defaultExportTimeout.
// Timeout 约束单次导出尝试。为零用 defaultExportTimeout。
Timeout time.Duration
// Log is the base logger. Nil uses a discard logger.
// Log 是基础日志器。nil 则用丢弃式日志器。
Log *slog.Logger
}
// NewPushOutlet builds the two-tier push outlet from cfg. It returns nil when
// the hook is disabled, so the pusher simply skips the export step. When it is
// enabled the primary tier is the self-hosted HTTP endpoint (present only when
// URL is set) and the fallback tier is Getui, which is not integrated yet.
// NewPushOutlet 由 cfg 构建两级推送出口。钩子关闭时它返回 nil,
// 因此推送器直接跳过导出步骤。启用时,
// 主级是自托管 HTTP 端点(仅当
// URL 已设置时存在),降级级是个推,尚未接入。
func NewPushOutlet(cfg PushOutletConfig) PushExporter {
if !cfg.Enabled {
return nil
@@ -90,19 +90,19 @@ func NewPushOutlet(cfg PushOutletConfig) PushExporter {
return &pushOutlet{primary: primary, log: log}
}
// pushOutlet is the two-tier exporter: self-hosted service first, Getui second.
// The fallback is nil until the Getui SDK is integrated, so a primary miss is
// logged and reported as not delivered rather than silently dropped.
// pushOutlet 是两级导出器:先自托管服务,再个推。
// 在个推 SDK 接入前降级级为 nil,因此主级未命中时
// 只记日志并上报未投递,而不是静默丢弃。
type pushOutlet struct {
primary PushExporter
fallback PushExporter
log *slog.Logger
}
// Export implements PushExporter. It tries the primary tier and, when that does
// not deliver, the fallback tier. With no fallback wired it logs the pending
// integration and returns (false, nil): the caller treats that as a miss, never
// as a reason to fail the proactive push.
// Export 实现 PushExporter。它先试主级,主级
// 未投递时再试降级级。未接降级级时它记录待
// 接入状态并返回 (false, nil):调用方把这当作未命中,
// 绝不当作让主动推送失败的理由。
func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
if o.primary != nil {
delivered, err := o.primary.Export(ctx, msg)
@@ -123,34 +123,34 @@ func (o *pushOutlet) Export(ctx context.Context, msg adapter.UnifiedMessage) (bo
return false, nil
}
// pushExportPayload is the JSON body POSTed to the self-hosted push service. It
// is deliberately small and stable: the service owns device targeting, the
// message carries only what is needed to render and de-duplicate it.
// pushExportPayload 是 POST 到自托管推送服务的 JSON 体。它
// 刻意小而稳定:设备寻址归该服务,消息
// 只携带渲染与去重所需的内容。
type pushExportPayload struct {
// Source identifies the producer so the service can route/filter.
// Source 标识生产者,好让服务能路由/过滤。
Source string `json:"source"`
// Title is a short display title (the persona name).
// Title 是简短显示标题(角色名)。
Title string `json:"title"`
// Content is the proactive message body (already stripped of internal
// decision markers by buildProactiveMessage).
// Content 是主动消息正文(内部决策标记
// 已由 buildProactiveMessage 剥掉)。
Content string `json:"content"`
// Platform is the outbound platform tag (cyrene-backthink or a concrete IM
// platform).
// Platform 是出站平台标签(cyrene-backthink 或具体的 IM
// 平台)。
Platform string `json:"platform"`
// Timestamp is the message time in Unix milliseconds.
// Timestamp 是消息时间,Unix 毫秒。
Timestamp int64 `json:"timestamp"`
}
// httpPushExporter posts the message to the self-hosted push service. A 2xx
// response means delivered; any other status or transport error is an error.
// The token travels only in the Authorization header and never reaches a log.
// httpPushExporter 把消息 POST 到自托管推送服务。2xx
// 响应表示已投递;其它任何状态或传输错误都算错误。
// token 只出现在 Authorization 头里,绝不进入日志。
type httpPushExporter struct {
url string
token string
client *http.Client
}
// Export implements PushExporter over HTTP POST JSON.
// Export 实现 PushExporter,走 HTTP POST JSON。
func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessage) (bool, error) {
body, err := json.Marshal(pushExportPayload{
Source: "cyrene/backthink",
@@ -182,8 +182,8 @@ func (e *httpPushExporter) Export(ctx context.Context, msg adapter.UnifiedMessag
return true, nil
}
// redact removes the endpoint URL from a transport error so a secret embedded in
// the URL (for example a query token) never reaches a log line.
// redact 从传输错误里移除端点 URL,因此 URL 中内嵌的
// 秘密(例如查询参数里的 token)绝不进入日志行。
func (e *httpPushExporter) redact(err error) error {
if err == nil || e.url == "" {
return err
+19 -19
View File
@@ -16,8 +16,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// mockExporter records exported messages and can block or fail on demand so the
// pusher hook's non-blocking / failure-tolerant behaviour is testable.
// mockExporter 记录已导出的消息,并可按需阻塞或失败,
// 使推送器钩子的非阻塞 / 容错行为可测。
type mockExporter struct {
mu sync.Mutex
msgs []adapter.UnifiedMessage
@@ -49,9 +49,9 @@ func (m *mockExporter) messages() []adapter.UnifiedMessage {
return append([]adapter.UnifiedMessage(nil), m.msgs...)
}
// TestPushOutletHTTPPrimaryDelivers proves the self-hosted service is the
// primary tier: the payload is POSTed with the bearer token and a 2xx response
// is reported as delivered.
// TestPushOutletHTTPPrimaryDelivers 证明自托管服务是
// 主级:负载带着 bearer token 被 POST,2xx 响应
// 被上报为已投递。
func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
t.Parallel()
type payload struct {
@@ -109,14 +109,14 @@ func TestPushOutletHTTPPrimaryDelivers(t *testing.T) {
}
}
// TestPushOutletUnreachableLogsGetuiFallback asserts an unreachable primary is a
// logged miss (not an error), names the pending Getui fallback, and never logs
// the token.
// TestPushOutletUnreachableLogsGetuiFallback 断言主级不可达是一次
// 记账的未命中(不是错误),会点出待接入的个推降级,且绝不记录
// token。
func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // now unreachable
srv.Close() // 此时已不可达
var buf bytes.Buffer
log := slog.New(slog.NewTextHandler(&buf, nil))
@@ -137,7 +137,7 @@ func TestPushOutletUnreachableLogsGetuiFallback(t *testing.T) {
}
}
// TestPushOutletHTTPErrorReportsMiss asserts a non-2xx primary response is a miss.
// TestPushOutletHTTPErrorReportsMiss 断言主级非 2xx 响应算未命中。
func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -155,8 +155,8 @@ func TestPushOutletHTTPErrorReportsMiss(t *testing.T) {
}
}
// TestNewPushOutletDisabledOrUnconfigured covers the two degraded shapes: the
// hook off (nil) and enabled without a primary URL (miss + Getui note).
// TestNewPushOutletDisabledOrUnconfigured 覆盖两种降级形态:钩子
// 关闭(nil)与启用但无主级 URL(未命中 + 个推备注)。
func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
t.Parallel()
if exp := NewPushOutlet(PushOutletConfig{Enabled: false, URL: "http://example.invalid"}); exp != nil {
@@ -178,8 +178,8 @@ func TestNewPushOutletDisabledOrUnconfigured(t *testing.T) {
}
}
// TestPusherExportsProactiveToOutlet asserts the hook mirrors the delivered
// proactive message to the outlet after the IM delivery.
// TestPusherExportsProactiveToOutlet 断言钩子在 IM 投递之后
// 把已投递的主动消息镜像到出口。
func TestPusherExportsProactiveToOutlet(t *testing.T) {
t.Parallel()
exp := &mockExporter{delivered: true}
@@ -205,9 +205,9 @@ func TestPusherExportsProactiveToOutlet(t *testing.T) {
}
}
// TestPusherExportHookNonBlockingAndFailureTolerant asserts a hung/failing outlet
// never delays the push and never turns a delivered proactive message into a
// failure.
// TestPusherExportHookNonBlockingAndFailureTolerant 断言挂死/失败的出口
// 绝不延迟推送,也绝不把已投递的主动消息变成
// 失败。
func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
t.Parallel()
started := make(chan struct{})
@@ -238,8 +238,8 @@ func TestPusherExportHookNonBlockingAndFailureTolerant(t *testing.T) {
}
}
// TestPusherRateLimitedPushIsNotExported asserts a rate-limited push never
// reaches the outlet: the hook mirrors real proactive sends only.
// TestPusherRateLimitedPushIsNotExported 断言被限流的推送绝不
// 到达出口:该钩子只镜像真实的主动发送。
func TestPusherRateLimitedPushIsNotExported(t *testing.T) {
t.Parallel()
exp := &mockExporter{delivered: true}
+28 -28
View File
@@ -11,10 +11,10 @@ import (
func TestPusher_AutoRoutesIM(t *testing.T) {
t.Parallel()
// The owner mandate: a default (auto) push reaches ONE channel over the IM
// bridge (the bridge picks her [渠道:...] choice, then her most recent platform,
// then the whitelist default) — not the active web client by default.
active := &mockActive{n: 2} // a web client is online but is NOT the default target
// 主人要求:默认(auto)推送经 IM 桥到达 ONE 个渠道
//(桥依次挑她的 [渠道:...] 选择、她最近的平台、
// 白名单默认)——默认不是活跃 Web 客户端。
active := &mockActive{n: 2} // 有 Web 客户端在线,但它不是默认目标
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
@@ -58,8 +58,8 @@ func TestPusher_WebHintRoutesActive(t *testing.T) {
func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
t.Parallel()
// A concrete decision channel is honored over the IM bridge, and exactly ONE
// target is used (the IM bridge carries it; the active client is not touched).
// 具体的决策渠道经 IM 桥被尊重,且只用 ONE 个
// 目标(由 IM 桥承载;活跃客户端不被触碰)。
active := &mockActive{n: 2}
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
@@ -85,8 +85,8 @@ func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
func TestPusher_NoIMSink_DegradesToActive(t *testing.T) {
t.Parallel()
// With no IM sink wired (backthink IM seam absent) the push degrades to the
// active web client so the owner is still reached rather than dropped.
// 未接线 IM 槽(backthink 的 IM 接缝缺失)时推送降级到
// 活跃 Web 客户端,好让主人仍被触达而不是被丢弃。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
@@ -156,8 +156,8 @@ func TestPusher_CancelledContext_Denied(t *testing.T) {
}
}
// TestPusher_RateLimit exercises the production rateGate over session.RateLimiter:
// with a limit of 1 push per window, only the first of three attempts passes.
// TestPusher_RateLimit 在 session.RateLimiter 上验证生产 rateGate:
// 窗口内 limit 为 1 次推送时,三次尝试中只有第一次通过。
func TestPusher_RateLimit(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -181,9 +181,9 @@ func TestPusher_RateLimit(t *testing.T) {
}
}
// TestRateGate_CooldownAfterSuccess verifies the post-push cooldown: after a
// successful delivery a further push is refused until the cooldown elapses,
// regardless of the configured window counter.
// TestRateGate_CooldownAfterSuccess 验证推送后冷却:一次成功
// 投递之后,在冷却期结束前再推送会被拒绝,
// 与已配置的窗口计数无关。
func TestRateGate_CooldownAfterSuccess(t *testing.T) {
t.Parallel()
t0 := time.Now()
@@ -191,7 +191,7 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 30 * time.Minute,
kindWindow: 0, // disabled to isolate the cooldown
kindWindow: 0, // 置零以单独验证冷却
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
}
@@ -208,16 +208,16 @@ func TestRateGate_CooldownAfterSuccess(t *testing.T) {
}
}
// TestRateGate_KindWindowLimitsSameFlavor verifies the per-flavor (问候/提醒/分享/
// 关心) limit: the same flavor cannot repeat within the kind window, while a
// different flavor and a later same-flavor push (after the window) still pass.
// TestRateGate_KindWindowLimitsSameFlavor 验证按风格(问候/提醒/分享/
// 关心)限制:同一风格不能在风格窗口内重复,而
// 不同风格与更晚的同一风格推送(窗口之后)仍可通过。
func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
t.Parallel()
t0 := time.Now()
g := &rateGate{
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 0, // disabled to isolate the kind window
cooldown: 0, // 置零以单独验证风格窗口
kindWindow: 2 * time.Hour,
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
@@ -238,10 +238,10 @@ func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
}
}
// TestPusher_EventBypassesGate verifies an event-driven push skips the PushGate:
// with a limit of 1 push per window, three consecutive event pushes are ALL
// delivered (owner mandate "该发就发"), each marked Bypassed and each advancing
// no cooldown/kind window.
// TestPusher_EventBypassesGate 验证事件驱动的推送跳过 PushGate:
// 窗口内 limit 为 1 次推送时,连续三次事件推送全部
// 被投递(主人要求 "该发就发"),每次都标记 Bypassed,且都不推进
// 冷却/风格窗口。
func TestPusher_EventBypassesGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -266,9 +266,9 @@ func TestPusher_EventBypassesGate(t *testing.T) {
}
}
// TestPusher_HeartbeatStillRateLimited verifies a heartbeat-driven push stays
// gate-governed: with a limit of 1 push per window, the first passes and the
// second is refused as rate_limited, never Bypassed.
// TestPusher_HeartbeatStillRateLimited 验证心跳驱动的推送仍
// 受门控管辖:窗口内 limit 为 1 次推送时,第一次通过,
// 第二次作为 rate_limited 被拒,绝不是 Bypassed。
func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
@@ -289,9 +289,9 @@ func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
}
}
// TestPusher_EventBypass_DoesNotAdvanceGate verifies an event-driven push does not
// consume the heartbeat limit: after an event push, a following heartbeat push
// still gets its own full allowance.
// TestPusher_EventBypass_DoesNotAdvanceGate 验证事件驱动的推送不会
// 消耗心跳额度:一次事件推送之后,紧随的心跳推送
// 仍拿到自己那份完整额度。
func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
+14 -14
View File
@@ -1,14 +1,14 @@
package backthink
// The production composition root builds the gateway hub and the channel bridge
// *after* backthink.New (the hub needs the loop, the bridge needs the loop and
// owner profile, both of which are assembled later), so the ActiveClient and
// IMAdapter sinks are unavailable at construction time. These setters inject them
// once the gateway / bridge exist and rebuild the pusher, keeping the resolved
// rate gate so a later push routes correctly.
// 生产组合根在 backthink.New *之后* 才构建 gateway hub 与 channel
// bridge(hub 需要 loop,bridge 需要 loop 与主人档案,
// 两者都在更晚时才组装),因此构造时拿不到 ActiveClient 与
// IMAdapter 槽。这些设置器在 gateway / bridge 存在后注入它们,
// 并重建推送器,同时保留已解出的
// 频率门,使之后的推送路由正确。
// SetActiveSink wires the production active web/native client sink (the gateway
// hub) and rebuilds the pusher. Call it after the gateway exists, before Start.
// SetActiveSink 接入生产用的活跃 Web/原生客户端槽(gateway
// hub)并重建推送器。请在 gateway 存在之后、Start 之前调用。
func (b *Backthink) SetActiveSink(active ActiveClient) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -16,8 +16,8 @@ func (b *Backthink) SetActiveSink(active ActiveClient) {
b.rebuildPusherLocked()
}
// SetIMSink wires the production IM bridge sink (the channel bridge) and rebuilds
// the pusher. Call it after the channel bridge exists, before Start.
// SetIMSink 接入生产用的 IM 桥槽(channel bridge)并重建
// 推送器。请在 channel bridge 存在之后、Start 之前调用。
func (b *Backthink) SetIMSink(im IMAdapter) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -25,14 +25,14 @@ func (b *Backthink) SetIMSink(im IMAdapter) {
b.rebuildPusherLocked()
}
// rebuildPusherLocked replaces the pusher from the current sinks, gate and push
// outlet. The caller must hold b.mu.
// rebuildPusherLocked 用当前槽位、频率门与推送出口替换
// 推送器。调用方必须持有 b.mu。
func (b *Backthink) rebuildPusherLocked() {
b.pusher = newPusher(b.deps.Active, b.deps.IM, b.gate, b.log).withExporter(b.deps.Exporter)
}
// currentPusher returns the active pusher under the lifecycle lock, so a sink
// swap never races with a concurrent thinking pass that is about to push.
// currentPusher 在生命周期锁下返回当前推送器,因此一次槽位
// 换入绝不会与即将推送的并发思考轮发生竞态。
func (b *Backthink) currentPusher() Pusher {
b.mu.Lock()
defer b.mu.Unlock()
+9 -9
View File
@@ -7,9 +7,9 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// TestBackthink_SetActiveSink_RebuildsPusher verifies the sink setters rebuild the
// pusher so a later push routes to the injected active client instead of the
// default nil-sink RouteNone pusher built at New.
// TestBackthink_SetActiveSink_RebuildsPusher 验证槽位设置器会重建
// 推送器,使之后的推送路由到注入的活跃客户端,而不是
// New 时构建的默认 nil 槽 RouteNone 推送器。
func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
@@ -42,8 +42,8 @@ func TestBackthink_SetActiveSink_RebuildsPusher(t *testing.T) {
}
}
// TestBackthink_SetIMSink_Fallback verifies a wired IM sink receives the push when
// no active client is online (so the owner is still reached).
// TestBackthink_SetIMSink_Fallback 验证没有活跃客户端在线时,
// 已接线的 IM 槽会收到推送(主人因此仍被触达)。
func TestBackthink_SetIMSink_Fallback(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
@@ -56,7 +56,7 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
t.Fatalf("new: %v", err)
}
active := &mockActive{n: 0} // no active client online
active := &mockActive{n: 0} // 没有活跃客户端在线
im := &mockIMAdapter{}
bt.SetActiveSink(active)
bt.SetIMSink(im)
@@ -72,9 +72,9 @@ func TestBackthink_SetIMSink_Fallback(t *testing.T) {
}
}
// TestBackthink_SinkSetters_ConcurrentSafe verifies the setter and the read of the
// current pusher do not race under the lifecycle lock, so an in-flight pass never
// touches a half-rebuilt pusher.
// TestBackthink_SinkSetters_ConcurrentSafe 验证设置器与对
// 当前推送器的读取在生命周期锁下不产生竞态,因此进行中的轮次绝不会
// 碰到半重建的推送器。
func TestBackthink_SinkSetters_ConcurrentSafe(t *testing.T) {
db := newTestDB(t)
bt, err := New(Config{}, Deps{
+17 -17
View File
@@ -11,33 +11,33 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// StateProvider supplies the user's current condition for the interrupt decision
// (design/12 §2). Production wires IoT/telemetry and calendar; tests inject a
// mock. It must be concurrency-safe.
// StateProvider 为打扰决策提供用户当前状况
// (design/12 §2)。生产接线 IoT/遥测与日历;测试注入
// mock。它必须并发安全。
type StateProvider interface {
// Current returns the user's current UserState. A nil-able error means "use
// a neutral state"; a hard error is logged by the caller and treated as
// neutral.
// Current 返回用户当前的 UserState。可 nil 的错误表示「用
// 中性状态」;硬错误由调用方记日志并按
// 中性处理。
Current(ctx context.Context) (UserState, error)
}
// IoTStateProvider is a best-effort StateProvider built over an iot.Manager. It
// scans the managed devices and reads heart-rate and sleep signals from any
// device whose id/name/type mentions the corresponding keyword (e.g. "heart",
// "心率", "sleep", "睡眠"). Work/busy come from a device/state flag; when nothing
// matches it returns a neutral state rather than an error, so the service still
// runs without full telemetry wiring.
// IoTStateProvider 是基于 iot.Manager 的尽力而为 StateProvider。它
// 扫描纳管设备,并从任何 id/名称/类型提到对应关键词的设备
// 读取心率与睡眠信号(例如 "heart"、
// "心率"、"sleep"、"睡眠")。工作/忙碌来自设备/状态标志;什么都不
// 匹配时它返回中性状态而不是错误,因此即便没有完整
// 遥测接线,服务也能运行。
type IoTStateProvider struct {
mgr *iot.Manager
log *slog.Logger
}
// NewIoTStateProvider wraps mgr as a StateProvider.
// NewIoTStateProvider 把 mgr 包装为 StateProvider。
func NewIoTStateProvider(mgr *iot.Manager, log *slog.Logger) *IoTStateProvider {
return &IoTStateProvider{mgr: mgr, log: logger.WithSource(baseLog(log), "services/backthink")}
}
// Current implements StateProvider.
// Current 实现 StateProvider。
func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
if p.mgr == nil {
return UserState{}, fmt.Errorf("backthink: state: %w", iot.ErrProviderNotRegistered)
@@ -71,7 +71,7 @@ func (p *IoTStateProvider) Current(ctx context.Context) (UserState, error) {
return state, nil
}
// numState reads the first numeric value among keys from a device's state map.
// numState 从设备状态 map 中按 keys 读取首个数值。
func numState(d iot.Device, keys ...string) (int, bool) {
for _, k := range keys {
v, ok := d.State[k]
@@ -94,13 +94,13 @@ func numState(d iot.Device, keys ...string) (int, bool) {
return 0, false
}
// boolState reads a bool (or a "sleep"/"asleep" string) among keys.
// boolState 在 keys 中读取一个 bool(或 "sleep"/"asleep" 字符串)。
func boolState(d iot.Device, keys ...string) bool {
b, _ := boolStateOK(d, keys...)
return b
}
// boolStateOK reports whether a bool-typed state flag was found and its value.
// boolStateOK 报告是否找到了 bool 类型的状态标志及其取值。
func boolStateOK(d iot.Device, keys ...string) (bool, bool) {
for _, k := range keys {
v, ok := d.State[k]
+4 -4
View File
@@ -9,8 +9,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// stateMockProvider is a tiny iot.Provider that returns a heart-rate watch, a
// sleep band and a working-calendar device, to exercise the state decoding.
// stateMockProvider 是一个极小的 iot.Provider,返回心率手表、
// 睡眠手环与工作日历设备,用于演练状态解码。
type stateMockProvider struct{}
func (stateMockProvider) Name() string { return "state-mock" }
@@ -73,8 +73,8 @@ func TestIoTStateProvider_EmptyNeutral(t *testing.T) {
t.Fatalf("iot.New: %v", err)
}
p := NewIoTStateProvider(mgr, nil)
// The mock provider has no heart-rate/sleep/working device, so it must yield
// a zero state instead of an error, keeping the service operable.
// 该 mock 提供者没有心率/睡眠/工作设备,所以它必须给出
// 零值状态而不是错误,保持服务可运行。
st, err := p.Current(context.Background())
if err != nil {
t.Fatalf("Current: %v", err)
+23 -23
View File
@@ -10,14 +10,14 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// defaultListLimit is the number of most-recent records returned by List when the
// caller passes a non-positive limit.
// defaultListLimit 是调用方传入非正 limit 时,List 返回的
// 最近记录条数。
const defaultListLimit = 50
// DDL for the thinking record store. at/created_at follow the project timestamp
// convention (RFC3339 with a timezone); trigger and conclusion are the free-text
// fields the panel renders; the push_* columns carry a decomposed PushResult so
// List need not parse a blob. The primary key is the project-standard auto id.
// 思考记录存储的 DDL。at/created_at 遵循项目时间戳
// 约定(带时区的 RFC3339);trigger 与 conclusion 是面板渲染的
// 自由文本字段;push_* 列携带分解后的 PushResult,
// 使 List 不必解析 blob。主键是项目标准的自增 id。
const createRecordsSQL = `
CREATE TABLE IF NOT EXISTS backthink_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -46,10 +46,10 @@ FROM backthink_records
ORDER BY id DESC
LIMIT ?`
// Record is one persisted thinking pass (design/12 §4), returned by Store.List
// and the service's List. Trigger is the source subsystem label, At the decision
// timestamp, Conclusion the runtime's output, ShouldPush the interrupt decision
// and PushResult how (or whether) the push went out.
// Record 是一轮已持久化的思考(design/12 §4),由 Store.List
// 与服务的 List 返回。Trigger 是来源子系统标签,At 是决策
// 时间戳,Conclusion 是运行时的输出,ShouldPush 是打扰决策,
// PushResult 是推送如何(或是否)发出。
type Record struct {
ID int64
Trigger string
@@ -59,17 +59,17 @@ type Record struct {
PushResult PushResult
}
// Store persists thinking records in SQLite. It creates its table and index in
// NewStore so the module is self-contained (no migration bookkeeping), mirrors
// how channel/session creates its own table. It is concurrency-safe by virtue of
// the underlying *sql.DB.
// Store 把思考记录持久化到 SQLite。它在
// NewStore 中建表与索引,因此本模块自包含(无需迁移记账),
// 与 channel/session 自建表的做法一致。它凭借底层的 *sql.DB
// 而并发安全。
type Store struct {
db *sql.DB
log *slog.Logger
}
// NewStore builds a Store, ensuring the backthink_records table and index exist.
// It returns ErrNilDB when db is nil. ctx is used for the DDL.
// NewStore 构建 Store,确保 backthink_records 表与索引存在。
// db 为 nil 时返回 ErrNilDB。ctx 用于 DDL。
func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error) {
if db == nil {
return nil, ErrNilDB
@@ -84,7 +84,7 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
return &Store{db: db, log: log}, nil
}
// Append persists r and returns the new record id.
// Append 持久化 r 并返回新记录 id。
func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
res, err := s.db.ExecContext(ctx, insertRecordSQL,
r.Trigger,
@@ -109,8 +109,8 @@ func (s *Store) Append(ctx context.Context, r Record) (int64, error) {
return id, nil
}
// List returns the most recent records newest-first. A non-positive limit selects
// defaultListLimit.
// List 返回最近的记录,最新的在前。非正的 limit 选用
// defaultListLimit。
func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
if limit <= 0 {
limit = defaultListLimit
@@ -158,7 +158,7 @@ func (s *Store) List(ctx context.Context, limit int) ([]Record, error) {
return records, nil
}
// boolInt maps a bool to the SQLite INTEGER 1/0 representation.
// boolInt 把 bool 映射为 SQLite 的 INTEGER 1/0 表示。
func boolInt(b bool) int {
if b {
return 1
@@ -166,11 +166,11 @@ func boolInt(b bool) int {
return 0
}
// intBool maps a SQLite INTEGER to a bool.
// intBool 把 SQLite 的 INTEGER 映射为 bool。
func intBool(n int) bool { return n != 0 }
// parseTime parses a stored RFC3339(nano/with-zone) timestamp, falling back to
// the zero time so a malformed row never breaks a listing.
// parseTime 解析存储的 RFC3339(带纳秒/时区)时间戳,失败时回退到
// 零值时间,因此格式错乱的行绝不会破坏列表查询。
func parseTime(s string) time.Time {
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
+1 -1
View File
@@ -38,7 +38,7 @@ func TestStore_AppendList(t *testing.T) {
if len(records) != 3 {
t.Fatalf("len = %d, want 3", len(records))
}
// Newest first (descending id).
// 最新的在前(按 id 降序)。
if records[0].ID != 3 || records[1].ID != 2 || records[2].ID != 1 {
t.Fatalf("order = %d,%d,%d, want 3,2,1", records[0].ID, records[1].ID, records[2].ID)
}
+16 -16
View File
@@ -7,29 +7,29 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// LoopThinker adapts a *loop.Loop into a Thinker. It is the production wiring
// that lets an autonomous background pass drive the full tool loop (memory
// injection, model steps, tool calls such as IoT/calendar/reminders) while seeing
// the main session's recent conversation and running under the owner environment.
// LoopThinker 把 *loop.Loop 适配为 Thinker。它是生产接线,
// 让一次自主后台轮驱动完整的工具循环(记忆
// 注入、模型步骤、IoT/日历/提醒等工具调用),同时看到
// 主会话的最近对话并在 owner 环境下运行。
type LoopThinker struct {
loop *loop.Loop
recent recentProvider
}
// NewLoopThinker wraps l as a Thinker over the given recent-messages provider
// (*history.Store in production; nil degrades the think to the guidance prompt
// alone, see mainContext).
// NewLoopThinker 把 l 包装成基于给定最近消息提供者的 Thinker
// (生产中是 *history.Store;nil 会让思考降级为仅用引导提示词,
// 见 mainContext)。
func NewLoopThinker(l *loop.Loop, recent recentProvider) *LoopThinker {
return &LoopThinker{loop: l, recent: recent}
}
// Think runs one unattended think through the wrapped loop and returns the
// conclusion text. It inherits the main session's recent conversation as input
// context and injects the owner session environment (MemWritable=true, ActorOwner,
// full tool set) so the think can read/write memory and call tools. The loop's
// Run session id is the service's own (not the main session id) so the think never
// collides with the live main session's durable checkpoint store (design/20).
// It returns ErrNoLoop when no loop was injected.
// Think 经被包装的 loop 跑一轮无人值守思考并返回
// 结论文本。它继承主会话的最近对话作为输入
// 上下文,并注入 owner 会话环境(MemWritable=true、ActorOwner、
// 完整工具集),因此该次思考能读写记忆并调用工具。loop 的
// Run 会话 id 是本服务自己的(不是主会话 id),所以该次思考绝不会
// 与活跃主会话的持久化检查点存储冲突(design/20)。
// 未注入 loop 时返回 ErrNoLoop。
func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (string, error) {
if t.loop == nil {
return "", ErrNoLoop
@@ -37,8 +37,8 @@ func (t *LoopThinker) Think(ctx context.Context, sessionID, trigger string) (str
recent := mainContext(ctx, t.recent)
messages := thinkInput(recent, trigger)
envCtx := loop.WithSessionEnv(ctx, loop.OwnerEnv())
// Mute UI broadcast: background reflection must not leak its intermediate
// reasoning into the user-facing chat stream (it only needs the conclusion).
// 静音 UI 广播:后台反思不得把其中间
// 推理泄漏进面向用户的 chat 流(它只需要结论)。
res, err := t.loop.Run(loop.WithMutedBroadcast(envCtx), sessionID, messages)
if err != nil {
return "", fmt.Errorf("backthink: think: %w", err)
+28 -28
View File
@@ -14,8 +14,8 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// fakeRecent is a recentProvider stub returning a fixed tail/error for the
// main-session context read.
// fakeRecent 是 recentProvider 的 stub,为
// 主会话语境读取返回固定的尾部/错误。
type fakeRecent struct {
msgs []history.Message
err error
@@ -25,8 +25,8 @@ func (f *fakeRecent) Recent(context.Context, string, int) ([]history.Message, er
return f.msgs, f.err
}
// captureAdapter is a model.Adapter that records every request it is handed so a
// test can assert on the messages the loop actually fed to the model.
// captureAdapter 是一个 model.Adapter,记录交给它的每个请求,好让
// 测试能对 loop 实际喂给模型的消息做断言。
type captureAdapter struct {
reqs []model.Request
}
@@ -42,9 +42,9 @@ func (c *captureAdapter) ChatStream(_ context.Context, req model.Request, _ func
return c.Chat(context.Background(), req, model.Options{})
}
// newThinkLoop builds a real loop over ad with a working manual memory-extraction
// path so a test can prove the owner environment is in effect (extractMemory runs
// only when MemWritable=true).
// newThinkLoop 在 ad 之上构建一个真实 loop,并带一条可用的手动记忆抽取
// 路径,好让测试能证明 owner 环境确实生效(extractMemory 只在
// MemWritable=true 时运行)。
func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
t.Helper()
a, err := agent.New("You are Cyrene.", nil, nil, agent.WithModel(ad, nil))
@@ -61,7 +61,7 @@ func newThinkLoop(t *testing.T, ad model.Adapter) (*loop.Loop, *memory.Store) {
return l, store
}
// newMemoryStore opens an isolated in-memory memory store for one test.
// newMemoryStore 为单个测试打开一个隔离的内存记忆存储。
func newMemoryStore(t *testing.T) *memory.Store {
t.Helper()
d, err := db.Open(context.Background(), ":memory:", nil)
@@ -76,7 +76,7 @@ func newMemoryStore(t *testing.T) *memory.Store {
return s
}
// modelText returns the concatenated text of a model message's text parts.
// modelText 返回一条 model 消息各文本片段拼接后的文本。
func modelText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
@@ -87,9 +87,9 @@ func modelText(m model.Message) string {
return b.String()
}
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression guards the rewrite:
// the guidance must drop the old suppression phrases and push warm, targeted
// proactive expression (design/20 §2).
// TestGuidancePrompt_EncouragesProactiveWithoutSuppression 守护这次改写:
// 引导必须去掉旧的抑制措辞,并推动温暖、有针对性的
// 主动表达(design/20 §2)。
func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
t.Parallel()
p := guidancePrompt("heartbeat")
@@ -105,8 +105,8 @@ func TestGuidancePrompt_EncouragesProactiveWithoutSuppression(t *testing.T) {
}
}
// TestMainContext_MapsRecentMessages proves the recent main-session tail is
// converted in order with roles mapped and image parts preserved.
// TestMainContext_MapsRecentMessages 证明主会话最近尾部被
// 按序转换、角色被映射、图片片段被保留。
func TestMainContext_MapsRecentMessages(t *testing.T) {
t.Parallel()
prov := &fakeRecent{msgs: []history.Message{
@@ -129,8 +129,8 @@ func TestMainContext_MapsRecentMessages(t *testing.T) {
}
}
// TestMainContext_Degrades ensures a nil provider or a failed read yields a nil
// tail so the think runs on the guidance prompt alone rather than failing.
// TestMainContext_Degrades 确保提供者为 nil 或读取失败都得到 nil
// 尾部,因此该次思考仅凭引导提示词运行,而不是失败。
func TestMainContext_Degrades(t *testing.T) {
t.Parallel()
if got := mainContext(context.Background(), nil); got != nil {
@@ -141,8 +141,8 @@ func TestMainContext_Degrades(t *testing.T) {
}
}
// TestThinkInput_AppendsGuidanceLast verifies the assembled input is the recent
// context followed by a single guidance turn.
// TestThinkInput_AppendsGuidanceLast 验证拼装出的输入是最近
// 语境后跟单个引导回合。
func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
t.Parallel()
in := thinkInput([]model.Message{model.TextMessage(model.RoleUser, "hi")}, "heartbeat")
@@ -154,10 +154,10 @@ func TestThinkInput_AppendsGuidanceLast(t *testing.T) {
}
}
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv drives a real think and asserts
// three things from design/20 P1: the model saw the main-session recent message,
// the guidance prompt carries no suppression, and the think ran under the owner
// environment (the manual extractor persists only when MemWritable=true).
// TestLoopThinker_Think_UsesMainContextAndOwnerEnv 驱动一次真实思考并
// 断言 design/20 P1 的三件事:模型看到了主会话的最近消息、
// 引导提示词不携带抑制措辞,且该次思考跑在 owner
// 环境之下(手动抽取器只在 MemWritable=true 时持久化)。
func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
ad := &captureAdapter{}
l, store := newThinkLoop(t, ad)
@@ -194,7 +194,7 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
t.Fatalf("input missing the guidance prompt: %+v", last.Messages)
}
// Owner env proved: ManualExtractor only persists under MemWritable=true.
// 已证明 owner 环境:ManualExtractor 只在 MemWritable=true 时持久化。
items, err := store.ListByPersona(context.Background(), "persona-a")
if err != nil {
t.Fatalf("ListByPersona: %v", err)
@@ -207,8 +207,8 @@ func TestLoopThinker_Think_UsesMainContextAndOwnerEnv(t *testing.T) {
}
}
// TestLoopThinker_NilHistoryDegrades keeps the pre-existing backpressure: with no
// history provider the think still runs (guidance prompt only) and never panics.
// TestLoopThinker_NilHistoryDegrades 保持既有的背压行为:没有
// 历史提供者时该次思考仍会运行(只用引导提示词)且绝不 panic。
func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
ad := &captureAdapter{}
l, _ := newThinkLoop(t, ad)
@@ -221,9 +221,9 @@ func TestLoopThinker_NilHistoryDegrades(t *testing.T) {
t.Fatal("no model request captured")
}
last := ad.reqs[len(ad.reqs)-1]
// With no history provider the input carries the guidance prompt and no
// main-session messages. (The agent prepends its own instruction system message,
// so we assert presence rather than an exact count.)
// 没有历史提供者时,输入携带引导提示词、不含
// 主会话消息。(agent 会自行前置一条指令系统消息,
// 所以我们断言存在性而非精确条数。)
foundGuidance := false
for _, m := range last.Messages {
if strings.Contains(modelText(m), guidancePrompt("heartbeat")) {
+30 -30
View File
@@ -7,22 +7,22 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// TriggerKind classifies what woke a thinking pass, so the push path can apply the
// right policy: a heartbeat is gate-governed (60min≤1 / cooldown / per-flavor), an
// event is a real signal the owner wants to hear about and bypasses the rate gate.
// TriggerKind 归类唤醒一轮思考的原因,好让推送路径套用
// 正确的策略:心跳受门控管辖(60min≤1 / 冷却 / 按风格),
// 事件是主人确实想听到的真实信号、绕过频率门。
type TriggerKind int
const (
// TriggerHeartbeat is the periodic autonomous reflection. Its proactive push is
// throttled by the PushGate so the owner is never spammed by empty reflections.
// TriggerHeartbeat 是周期性自主反思。它的主动推送受
// PushGate 限流,使主人绝不会被空洞的反思刷屏。
TriggerHeartbeat TriggerKind = iota
// TriggerEvent is a real event (IoT device change, reminder, owner-relevant
// system event). Its push bypasses the PushGate — "该发就发" — subject only to
// same-event dedup.
// TriggerEvent 是真实事件(IoT 设备变化、提醒、与主人相关的
// 系统事件)。它的推送绕过 PushGate——"该发就发"——只受
// 同一事件去重约束。
TriggerEvent
)
// String returns a stable label for logs and records.
// String 返回用于日志与记录的稳定标签。
func (k TriggerKind) String() string {
switch k {
case TriggerEvent:
@@ -34,40 +34,40 @@ func (k TriggerKind) String() string {
}
}
// Bus subjects the service subscribes to for event-triggered thinking. They are
// the contract defined by design/12 §1: an IoT state change, a reminder firing,
// or a generic system event.
// 本服务为事件驱动思考订阅的总线主题。它们是
// design/12 §1 定义的契约:一次 IoT 状态变化、一次提醒触发,
// 或一次泛化的系统事件。
const (
// SubjectReminderTrigger is the bus subject on which reminders fire.
// SubjectReminderTrigger 是提醒触发所用的总线主题。
SubjectReminderTrigger = "reminder.trigger"
// SubjectSystemEvent is the bus subject for generic system events.
// SubjectSystemEvent 是泛化系统事件的总线主题。
SubjectSystemEvent = "system.event"
)
// SystemEvent is the optional typed payload a caller may publish to
// SubjectSystemEvent to convey a system notice. Import defaults to Normal. Under
// design/12 §6 策略一, a low-value system notice (a join/leave ping, generic
// platform noise) is downgraded to a registration-only record and does not wake a
// full thinking pass; only a high-importance (owner-relevant) system event thinks,
// so a busy public platform cannot trigger a flood of expensive reflections.
// SystemEvent 是调用方可发布到 SubjectSystemEvent 以传达
// 系统通知的可选类型化负载。Import 默认为 Normal。在
// design/12 §6 策略一之下,低价值的系统通知(加入/退出 ping、泛化的
// 平台噪声)被降级为仅登记一条记录,不唤醒
// 一整轮思考;只有高重要度(与主人相关)的系统事件才思考,
// 因此繁忙的公开平台无法触发昂贵反思的洪流。
type SystemEvent struct {
Summary string
Import Importance
}
// Trigger describes what woke a thinking pass: the source subsystem, a
// human-readable summary and the event's estimated importance.
// Trigger 描述唤醒一轮思考的原因:来源子系统、
// 可读摘要与事件的估计重要度。
type Trigger struct {
Source string
Summary string
Import Importance
// Kind records whether this pass was the periodic heartbeat or a real event, so
// the push path can bypass the rate gate for a genuine event (owner mandate).
// Kind 记录本轮是周期性心跳还是真实事件,好让
// 推送路径能为真实事件绕过频率门(主人要求)。
Kind TriggerKind
}
// heartbeatTrigger is the periodic, low-importance trigger. Low importance means
// the pass rarely interrupts the user, honouring "默认不发送 chat 类型消息".
// heartbeatTrigger 是周期性、低重要度的触发。低重要度意味着
// 该轮很少打扰用户,遵守 "默认不发送 chat 类型消息"。
func heartbeatTrigger() Trigger {
return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat}
}
@@ -84,10 +84,10 @@ func startupTrigger() Trigger {
return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat}
}
// triggerForEvent maps a bus event to a Trigger. An IoT device change is treated
// as normal importance, a reminder firing as high importance, and a generic
// system event as normal importance. Every event is tagged TriggerEvent so its
// proactive push bypasses the heartbeat PushGate.
// triggerForEvent 把总线事件映射为 Trigger。IoT 设备变化按
// 普通重要度处理,提醒触发按高重要度,泛化的
// 系统事件按普通重要度。每个事件都打上 TriggerEvent,因此其
// 主动推送绕过心跳的 PushGate。
func triggerForEvent(e bus.Event) Trigger {
switch e.Subject {
case iot.DeviceUpdateSubject: