8e9bd09072
真因:零件(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>
196 lines
5.8 KiB
Go
196 lines
5.8 KiB
Go
// claude_api — 呼叫 Mira daemon(Hetzner 上跑的 Claude Agent SDK 服務)
|
||
//
|
||
// 架構決策(2026-05-06):
|
||
// 不直打 Anthropic Messages API(OAuth 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"` // 預設 25000(daemon 協商模式上限)
|
||
Model string `json:"model"` // 'haiku' / 'sonnet' / 'opus',預設 haiku(daemon 端)
|
||
CallbackURL string `json:"callback_url"` // optional:daemon 完成 task 時 POST 此 URL 通知(Resumable workflow,SDD: 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 {
|
||
// 預設 120s:daemon 協商期會在 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)
|
||
}
|