From de3af0cc654d3f4221949c86fcc3f59312dacce0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 13:18:32 +0100 Subject: [PATCH] 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 --- go/buscalls/answer.go | 50 ++++++++++++++++++++++++++++++++++++++++ go/packages_list_test.go | 19 +++++++++++++++ go/views/follow.go | 17 ++++++-------- 3 files changed, 76 insertions(+), 10 deletions(-) create mode 100644 go/buscalls/answer.go diff --git a/go/buscalls/answer.go b/go/buscalls/answer.go new file mode 100644 index 0000000..afefd96 --- /dev/null +++ b/go/buscalls/answer.go @@ -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 +} diff --git a/go/packages_list_test.go b/go/packages_list_test.go index a012bb4..633ea39 100644 --- a/go/packages_list_test.go +++ b/go/packages_list_test.go @@ -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 diff --git a/go/views/follow.go b/go/views/follow.go index 5c005f7..6192bf6 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -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) } }