package reverse_think import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "time" "git.flytoex.net/yuanwei/flyto-agent/pkg/counterfactual" ) // Defaults for Client fields. Each is overridable on construction. // // Client 字段默认值, 构造时可覆盖. const ( // DefaultEndpoint is the MiniMax Anthropic-compatible messages // endpoint per ~/.claude/skills/reverse-think/SKILL.md. External // clients can swap to any Anthropic-protocol-compatible server. // // DefaultEndpoint 是 SKILL.md 记录的 MiniMax Anthropic 兼容 messages // 端点. 外部调用方可换成任何 Anthropic 协议兼容服务器. DefaultEndpoint = "https://api.minimaxi.com/anthropic/v1/messages" // DefaultModel is the recommended highspeed variant. Slower variants // (e.g. MiniMax-M2.7) work but burn more wall-time on the same call // budget. // // DefaultModel 是推荐的 highspeed 变体. 慢变体 (如 MiniMax-M2.7) 也能 // 跑但同 call 配额下延迟更高. DefaultModel = "MiniMax-M2.7-highspeed" // DefaultMaxTokens is the thinking + text shared budget. Setting // below 4000 risks empty text output (thinking eats the pool); see // SKILL.md "Important" callout. // // DefaultMaxTokens 是 thinking + text 共享预算. 低于 4000 风险 text // 输出空 (thinking 吃光池); 见 SKILL.md "Important" 段. DefaultMaxTokens = 8000 // DefaultTimeout caps a single Run call. MiniMax highspeed typically // returns 5-15s on substantive prompts (per SKILL.md empirical data); // a 90s cap leaves slack for cold-start latency without hanging the // caller indefinitely. // // DefaultTimeout 限单次 Run. MiniMax highspeed 实测 5-15s (SKILL.md 数据); // 90s 给冷启动留 slack 同时避免无限挂调用方. DefaultTimeout = 90 * time.Second // anthropicVersion is the protocol version header. Pinned to the // value SKILL.md verified against. // // anthropicVersion 是协议版本 header. 固定到 SKILL.md 验证过的值. anthropicVersion = "2023-06-01" ) // Errors returned by Client.Run. // // Client.Run 返回的错误. var ( ErrAPIKeyRequired = errors.New("reverse_think: APIKey required") ErrEndpointFailed = errors.New("reverse_think: endpoint returned non-2xx") ErrNoTextContent = errors.New("reverse_think: response has no text content block") ErrParseDeliverable = errors.New("reverse_think: failed to parse Deliverable JSON from response") ) // Client wraps the HTTP roundtrip + prompt rendering + Deliverable parsing. // Zero value is unusable -- APIKey must be set; other fields default at Run // time when zero. // // Client 封装 HTTP roundtrip + prompt 渲染 + Deliverable 解析. 零值不可用 -- // APIKey 必填; 其他字段零值时 Run 时使用默认值. type Client struct { // APIKey for the Anthropic-compatible endpoint. Required. // // APIKey 调用 Anthropic 兼容端点的密钥. 必填. APIKey string // Endpoint URL. Empty = DefaultEndpoint. // // Endpoint URL. 空 = DefaultEndpoint. Endpoint string // Model name. Empty = DefaultModel. // // Model 名. 空 = DefaultModel. Model string // MaxTokens is the shared thinking + text budget. Zero = DefaultMaxTokens. // // MaxTokens 是 thinking + text 共享预算. 零值 = DefaultMaxTokens. MaxTokens int // HTTPClient is the underlying HTTP client. Nil = a new // http.Client with DefaultTimeout. Inject for test (httptest server) // or for custom transport (proxy / mTLS). // // HTTPClient 是底层 HTTP 客户端. nil = 新 http.Client + DefaultTimeout. // 注入用于测试 (httptest 服务器) 或自定义传输 (代理 / mTLS). HTTPClient *http.Client // Now returns the current time. Override in test for deterministic // OccurredAt; nil = time.Now. // // Now 返回当前时间. 测试中覆盖以拿到确定 OccurredAt; nil = time.Now. Now func() time.Time } // anthropicRequest mirrors the request shape SKILL.md jq's together. Kept // unexported because callers should not need to construct it manually. // // anthropicRequest 对齐 SKILL.md jq 拼的请求体形状. 不导出, 调用方不需手工构造. type anthropicRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` Messages []anthropicMessage `json:"messages"` } type anthropicMessage struct { Role string `json:"role"` Content string `json:"content"` } // anthropicResponse captures the subset SKILL.md's jq selector reads: // content blocks of type "text" carry the JSON deliverable. // // anthropicResponse 取 SKILL.md jq selector 读的子集: type=="text" 的内容块 // 携带 JSON deliverable. type anthropicResponse struct { Content []anthropicContentBlock `json:"content"` } type anthropicContentBlock struct { Type string `json:"type"` Text string `json:"text"` } // Run renders the prompt, hits the endpoint, parses the response, and // returns a validated counterfactual.Deliverable. ToolName, Step, // DecisionID are taken from the supplied annotations argument and stamped // onto the returned Deliverable; OccurredAt is stamped from c.Now() (or // time.Now if c.Now is nil). // // CLEVER: We do NOT auto-strip code-fence wrappers (```json...```). Per // SKILL.md prompt the LLM is told "直接输出 JSON, 不要任何额外文字"; if it // still wraps, that is an LLM compliance bug worth surfacing as // ErrParseDeliverable rather than papering over silently. Replacement // behaviour is hard to make safe (a fence inside a string field gets // mangled). // // Run 渲染 prompt, 命中端点, 解析响应, 返回经 Validate 的 Deliverable. // ToolName / Step / DecisionID 由 annotations 参数提供并 stamp 入返回 // Deliverable; OccurredAt 由 c.Now() (c.Now 为 nil 时用 time.Now) 打戳. // // 精妙 (CLEVER): 不自动剥 code-fence 包装 (```json...```). SKILL.md 已让 LLM // "直接输出 JSON, 不要任何额外文字"; LLM 仍包装是其遵守度 bug, 应以 // ErrParseDeliverable 暴露而非静默兜底. 替换行为难做安全 (字符串字段内的 // fence 会被弄坏). func (c *Client) Run(ctx context.Context, p Prompt, annotations Annotations) (*counterfactual.Deliverable, error) { if c.APIKey == "" { return nil, ErrAPIKeyRequired } prompt, err := Render(p) if err != nil { return nil, err } req := anthropicRequest{ Model: valueOrDefault(c.Model, DefaultModel), MaxTokens: intOrDefault(c.MaxTokens, DefaultMaxTokens), Messages: []anthropicMessage{ {Role: "user", Content: prompt}, }, } body, err := json.Marshal(req) if err != nil { return nil, fmt.Errorf("reverse_think: marshal request: %w", err) } endpoint := valueOrDefault(c.Endpoint, DefaultEndpoint) httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("reverse_think: build request: %w", err) } httpReq.Header.Set("Authorization", "Bearer "+c.APIKey) httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("anthropic-version", anthropicVersion) httpClient := c.HTTPClient if httpClient == nil { httpClient = &http.Client{Timeout: DefaultTimeout} } resp, err := httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("reverse_think: HTTP roundtrip: %w", err) } defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("reverse_think: read response body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("%w: status=%d body=%s", ErrEndpointFailed, resp.StatusCode, truncate(string(respBody), 256)) } var ar anthropicResponse if err := json.Unmarshal(respBody, &ar); err != nil { return nil, fmt.Errorf("reverse_think: parse Anthropic response envelope: %w", err) } text := pickTextBlock(ar) if text == "" { return nil, ErrNoTextContent } var d counterfactual.Deliverable if err := json.Unmarshal([]byte(text), &d); err != nil { return nil, fmt.Errorf("%w: %v (raw text: %s)", ErrParseDeliverable, err, truncate(text, 256)) } // Stamp metadata the LLM cannot know. // // 打 LLM 不知道的元数据. d.ToolName = annotations.ToolName d.Step = annotations.Step d.DecisionID = annotations.DecisionID now := time.Now if c.Now != nil { now = c.Now } d.OccurredAt = now().UTC() if err := d.Validate(); err != nil { return nil, fmt.Errorf("%w: %v", ErrParseDeliverable, err) } return &d, nil } // Annotations are the metadata the LLM does not know but the Deliverable // schema records: the originating tool name, which CLAUDE.md article-1 // step is being run, and an optional decision identifier for downstream // correlation. // // Annotations 是 LLM 不知道但 Deliverable schema 要记录的元数据: 触发的 // 工具名 / 跑哪一步 (CLAUDE.md 五步) / 可选关联决策 id. type Annotations struct { ToolName string Step string DecisionID string } // pickTextBlock walks content blocks and returns the first text block's // Text. Returns "" if none. Anthropic responses can mix text and // thinking blocks; we ignore thinking deliberately (caller wants the // final answer, not the model's chain of thought). // // pickTextBlock 遍历 content 块返回首个 text 块的 Text. 没有返回 "". Anthropic // 响应可混 text 与 thinking, 我们刻意忽略 thinking (调用方要最终答案, 不要 // 模型思考过程). func pickTextBlock(r anthropicResponse) string { for _, b := range r.Content { if b.Type == "text" { return b.Text } } return "" } func valueOrDefault(v, def string) string { if v == "" { return def } return v } func intOrDefault(v, def int) int { if v == 0 { return def } return v } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "...(truncated)" }