Files
Arcrun/registry/components/claude_api/main.go
uncle6me-web 8e9bd09072 fix(engine): 回應太大就說「回應太大」——不再冒充「請求失敗」(Arcrun#92)
真因:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
claude_api 1MB)。回應超過這個大小時,wasi-shim 的 writeOut 照寫不誤:

    new Uint8Array(buf, outPtr, data.length).set(data)

data 比零件的 outBuf 大 → 覆寫零件堆積體,零件接著 outBuf[:outLen] 切片 panic;
或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句 "HTTP request failed"。
使用者照那句去查連線/URL/防火牆,方向全錯。

修法(容量握手,不改 host function 簽名、向後相容):
- 零件呼叫前把 outBuf 長度預先寫進 *outLenPtr(宣告容量)
- host 在寫回前讀這個值當上限;塞不下就**不寫**(不再覆寫零件記憶體),回新的
  HOST_TOO_LARGE=3
- http_request host fn 收到 3 → 改寫一段講真話的 error envelope(實際大小+上限+
  「不是連線失敗」+分頁/篩選的具體做法+機器可讀 code/actual_bytes/limit_bytes),
  沿用既有 parsed["error"] 判定鏈原樣送到使用者面前
- 舊零件沒宣告容量(讀到 0)→ 維持舊行為。不可硬套 64KB 預設:各零件緩衝區大小不同,
  硬套會把原本正常的大回應誤判成「太大」,那只是換一種說謊

同一條路徑上另一個「訊息與真因脫節」一併修:component-loader 的 makeHttpRunner
`try res.json() catch res.text()`,在零件回非 JSON 時 body 已被消費 → 丟
"Body has already been used",與真因無關(同檔 readBodyOnce 的註解早就寫明這個坑)。
改成只讀一次。

驗證狀態(誠實標示,mindset §7):
- 通:5 顆零件 tinygo build 全過,wasm 已重編進 .component-builds/
  (claude_api 依 .gitignore 慣例不入庫,由部署端重編)
- 未跑:runtime 驗證。本 session 的權限層擋掉 node/vitest/wasmtime,
  before/after 實測輸出待人跑 scripts/repro-oversize-response.mjs
- 未做:.worker-builds/ 重編(需 node scripts/build-worker-artifacts.mjs),
  否則修法不會進 self-hosted 安裝路徑(Arcrun#93 同款陷阱)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:49:22 +08:00

196 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// claude_api — 呼叫 Mira daemonHetzner 上跑的 Claude Agent SDK 服務)
//
// 架構決策(2026-05-06):
// 不直打 Anthropic Messages APIOAuth token 限制 system prompt 角色 → rate_limit_error
// 改透過已部署的 cloud-cto Mira daemon (https://mira.uncle6.me/mira/execute)
// 該 daemon 用 Claude Agent SDK,已內建 Mira persona,可長執行任務
//
// SDD: polaris/mira/.agents/specs/mira-app/design.md §6(五個 P0 零件)
//
//go:build tinygo
package main
import (
"encoding/json"
"io"
"os"
"strconv"
"unsafe"
)
// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
const hostTooLarge uint32 = 3
//go:wasmimport u6u http_request
func hostHttpRequest(
urlPtr uintptr, urlLen uint32,
methodPtr uintptr, methodLen uint32,
headersPtr uintptr, headersLen uint32,
bodyPtr uintptr, bodyLen uint32,
outPtr uintptr, outLenPtr uintptr,
) uint32
type Input struct {
MiraURL string `json:"mira_url"` // 預設 https://mira.uncle6.me
MiraToken string `json:"mira_token"` // Mira daemon Bearer token
Prompt string `json:"prompt"` // 必填:要傳給 Mira 的訊息
TimeoutMS int `json:"timeout_ms"` // 預設 25000daemon 協商模式上限)
Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haikudaemon 端)
CallbackURL string `json:"callback_url"` // optionaldaemon 完成 task 時 POST 此 URL 通知(Resumable workflowSDD: resumable-workflow
}
var dummy [1]byte
func safePtr(b []byte) (uintptr, uint32) {
if len(b) == 0 {
return uintptr(unsafe.Pointer(&dummy[0])), 0
}
return uintptr(unsafe.Pointer(&b[0])), uint32(len(b))
}
func main() {
raw, err := io.ReadAll(os.Stdin)
if err != nil {
writeError("failed to read stdin: " + err.Error())
return
}
var input Input
if err := json.Unmarshal(raw, &input); err != nil {
writeError("invalid input JSON: " + err.Error())
return
}
if input.MiraToken == "" {
writeError("mira_token 必填(Mira daemon Bearer token")
return
}
if input.Prompt == "" {
writeError("prompt 必填")
return
}
miraURL := input.MiraURL
if miraURL == "" {
miraURL = "https://mira.uncle6.me"
}
timeoutMS := input.TimeoutMS
if timeoutMS <= 0 {
// 預設 120sdaemon 協商期會在 25s 切非同步 + callback
// callback_url 存在時,timeout 上限不重要(daemon 會 fire callback 不論多久)
timeoutMS = 120000
}
// Mira daemon /execute 介面
body := map[string]interface{}{
"prompt": input.Prompt,
"timeout_ms": timeoutMS,
}
if input.Model != "" {
body["model"] = input.Model
}
if input.CallbackURL != "" {
body["callback_url"] = input.CallbackURL
}
bodyBytes, _ := json.Marshal(body)
headers := map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer " + input.MiraToken,
}
headersBytes, _ := json.Marshal(headers)
url := miraURL + "/mira/execute"
urlBytes := []byte(url)
methodBytes := []byte("POST")
outBuf := make([]byte, 1024*1024) // 1MB
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段
// 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。
outLen := uint32(len(outBuf))
urlPtr, urlLen := safePtr(urlBytes)
methodPtr, methodLen := safePtr(methodBytes)
headersPtr, headersLen := safePtr(headersBytes)
bodyPtr, bodyLenU := safePtr(bodyBytes)
result := hostHttpRequest(
urlPtr, urlLen,
methodPtr, methodLen,
headersPtr, headersLen,
bodyPtr, bodyLenU,
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
)
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區
if result == hostTooLarge {
writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" +
"這不是連線失敗,也不是 Mira 沒回應。" +
"做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。")
return
}
if result != 0 {
writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。")
return
}
respStr := string(outBuf[:outLen])
var resp map[string]interface{}
if err := json.Unmarshal([]byte(respStr), &resp); err != nil {
writeError("Mira returned non-JSON: " + respStr)
return
}
// 偵測錯誤回應
if errObj, hasErr := resp["error"]; hasErr {
errBytes, _ := json.Marshal(errObj)
writeError("Mira error: " + string(errBytes))
return
}
// daemon 回應格式:
// 同步完成: {"task_id":"...","status":"done","output":"...","model":"..."}
// 非同步: {"task_id":"...","status":"running","estimated_seconds":N}
status, _ := resp["status"].(string)
if status == "running" {
// 還沒完成,回傳 task_id 給 caller 自己 polling
out, _ := json.Marshal(map[string]interface{}{
"success": true,
"pending": true,
"task_id": resp["task_id"],
"estimated_seconds": resp["estimated_seconds"],
"poll_url": miraURL + "/mira/execute/" + toString(resp["task_id"]),
})
os.Stdout.Write(out)
return
}
// status == "done" 的場景
out := map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"text": resp["output"],
"task_id": resp["task_id"],
"model": resp["model"],
},
}
outJSON, _ := json.Marshal(out)
os.Stdout.Write(outJSON)
}
func toString(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}
func writeError(msg string) {
out, _ := json.Marshal(map[string]interface{}{"success": false, "error": msg})
os.Stdout.Write(out)
}