buscalls.Unwrap: one reading of a tool's answer whatever wraps it

A JSON string, the content list, {output, ok, answer} or the tool runner's {result, error}: mesh-config's
unwrapAnswer and the distribution's copy take it, and views.AskVia reads through it.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 13:18:32 +01:00
parent 3249b30e8d
commit de3af0cc65
3 changed files with 76 additions and 10 deletions
+50
View File
@@ -0,0 +1,50 @@
package buscalls
import "encoding/json"
// Unwrap reads a tool's answer whatever wraps it on its way from the tool runner (the lead's ruling 2026-11-04 13:12:
// one reading, not a copy per module): a JSON string holding the answer; the protocol's content list, whose first
// text is the answer and whose isError says it failed; the {output, ok, answer} form; the tool runner's {result,
// error} envelope, whose error is the call's. It answers the answer itself, the words said with it (the output, the
// error, or the answer's own text), and whether the tool says it succeeded. A bare answer is itself, succeeded.
func Unwrap(raw json.RawMessage) (answer json.RawMessage, said string, ok bool) {
var s string
if json.Unmarshal(raw, &s) == nil {
return Unwrap(json.RawMessage(s))
}
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return raw, string(raw), true
}
if content, has := m["content"]; has {
var items []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
inner, out, ok := Unwrap(json.RawMessage(items[0].Text))
return inner, out, ok && !e.IsError
}
}
var v struct {
Output string `json:"output"`
OK *bool `json:"ok"`
Answer json.RawMessage `json:"answer"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &v) == nil {
switch {
case v.OK != nil:
return v.Answer, v.Output, *v.OK
case v.Error != "":
return nil, v.Error, false
case len(v.Result) > 0:
return Unwrap(v.Result)
}
}
return raw, string(raw), true
}
+19
View File
@@ -192,6 +192,25 @@ func TestMeshSDKHoldsOnlyWhatEveryModuleShares(t *testing.T) {
if string(entry) != `{"instance":"listing/a17","sequence":3}` {
t.Errorf("an index entry is %s", entry)
}
// A tool's answer reads the same whatever wraps it (buscalls.Unwrap, the lead's ruling 2026-11-04 13:12).
for _, c := range []struct {
name, raw, answer, said string
ok bool
}{
{"bare", `{"a":1}`, `{"a":1}`, `{"a":1}`, true},
{"a JSON string", `"{\"a\":1}"`, `{"a":1}`, `{"a":1}`, true},
{"the content list", `{"content":[{"type":"text","text":"{\"a\":1}"}]}`, `{"a":1}`, `{"a":1}`, true},
{"the content list, failed", `{"content":[{"type":"text","text":"no"}],"isError":true}`, `no`, `no`, false},
{"output, ok, answer", `{"output":"done","ok":true,"answer":{"a":1}}`, `{"a":1}`, "done", true},
{"output, not ok", `{"output":"refused","ok":false}`, ``, "refused", false},
{"the runner's result", `{"result":{"a":1}}`, `{"a":1}`, `{"a":1}`, true},
{"the runner's error", `{"error":"no such tool"}`, ``, "no such tool", false},
} {
answer, said, ok := buscalls.Unwrap(json.RawMessage(c.raw))
if string(answer) != c.answer || said != c.said || ok != c.ok {
t.Errorf("%s: unwrapped %s, %q, %v; want %s, %q, %v", c.name, answer, said, ok, c.answer, c.said, c.ok)
}
}
// 1966/AC5: an apply naming the tool call it serves, and its answer with the state behind, round-trip.
for _, c := range []struct {
name string
+7 -10
View File
@@ -11,6 +11,8 @@ import (
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/buscalls"
)
// Follower is a reader's picture of one state of an owner (ADR 0615 §4 to §6): it asks the owner's first-view tool at
@@ -353,7 +355,7 @@ func (f *Follower) Gap(ctx context.Context, why string) error {
}
// AskVia is a Follower's Ask through a call of the owner's tool (process.Conn.Call, or stdio.Ask through the tool
// runner), decoding its answer: bare, or in the tool runner's envelope {result, error}, whose error is the ask's.
// runner), decoding its answer whatever wraps it (buscalls.Unwrap): an error the tool says is the ask's.
func AskVia(call func(key string, body any) (json.RawMessage, error), tool string) func(context.Context, string) (Answer, error) {
return func(_ context.Context, after string) (Answer, error) {
body := map[string]any{}
@@ -364,16 +366,11 @@ func AskVia(call func(key string, body any) (json.RawMessage, error), tool strin
if err != nil {
return Answer{}, err
}
var wrapped struct {
Result json.RawMessage `json:"result"`
Error string `json:"error"`
}
if json.Unmarshal(raw, &wrapped) == nil && (len(wrapped.Result) > 0 || wrapped.Error != "") {
if wrapped.Error != "" {
return Answer{}, fmt.Errorf("%s answered: %s", tool, wrapped.Error)
}
raw = wrapped.Result
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return Answer{}, fmt.Errorf("%s answered: %s", tool, said)
}
raw = answer
return DecodeAnswer(raw)
}
}