a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
638 lines
18 KiB
Go
638 lines
18 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
// federationReadTools is the allowlist of read traversal tools eligible
|
|
// for remote fan-out. Anything not here (and everything in MutatingTools)
|
|
// is never federated.
|
|
var federationReadTools = map[string]bool{
|
|
"find_usages": true,
|
|
"get_callers": true,
|
|
"get_call_chain": true,
|
|
"find_implementations": true,
|
|
"get_dependents": true,
|
|
"search_symbols": true,
|
|
"smart_context": true,
|
|
}
|
|
|
|
// localSchemaMajor is this daemon's graph-schema major version. A remote
|
|
// advertising an incompatible major is refused (never federated). Kept in
|
|
// sync with the value /v1/health advertises (server.SchemaVersion).
|
|
const localSchemaMajor = 1
|
|
|
|
// FederationConfig carries the tunable knobs (from .gortex.yaml's
|
|
// federation: block). Zero values fall back to sane defaults.
|
|
type FederationConfig struct {
|
|
PerRemoteTimeout time.Duration
|
|
Budget time.Duration
|
|
BreakerThreshold int
|
|
BreakerCooldown time.Duration
|
|
HealthTTL time.Duration
|
|
NameKeyedFallback bool
|
|
}
|
|
|
|
func (c FederationConfig) withDefaults() FederationConfig {
|
|
if c.PerRemoteTimeout <= 0 {
|
|
c.PerRemoteTimeout = 2 * time.Second
|
|
}
|
|
if c.Budget <= 0 {
|
|
c.Budget = 3 * time.Second
|
|
}
|
|
if c.BreakerThreshold <= 0 {
|
|
c.BreakerThreshold = 3
|
|
}
|
|
if c.BreakerCooldown <= 0 {
|
|
c.BreakerCooldown = 30 * time.Second
|
|
}
|
|
if c.HealthTTL <= 0 {
|
|
c.HealthTTL = 30 * time.Second
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Federator fans an allowlisted read tool out to enabled remotes after
|
|
// the local result is in hand, and merges the responses with provenance.
|
|
// It NEVER mutates a stored *graph.Node — it works on serialized bytes,
|
|
// so json.Unmarshal already yields detached copies; provenance lives only
|
|
// in the response-side origins map, never on a node.
|
|
type Federator struct {
|
|
cfg FederationConfig
|
|
clientFor func(ServerEntry) (*ServerClient, error)
|
|
breaker *circuitBreaker
|
|
health *healthCache
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewFederator builds a Federator. clientFor reuses the router's client
|
|
// cache so connections are shared.
|
|
func NewFederator(cfg FederationConfig, clientFor func(ServerEntry) (*ServerClient, error), logger *zap.Logger) *Federator {
|
|
cfg = cfg.withDefaults()
|
|
if logger == nil {
|
|
logger = zap.NewNop()
|
|
}
|
|
return &Federator{
|
|
cfg: cfg,
|
|
clientFor: clientFor,
|
|
breaker: newCircuitBreaker(cfg.BreakerThreshold, cfg.BreakerCooldown),
|
|
health: newHealthCache(cfg.HealthTTL),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// FederationMeta is the response-side provenance block (JSON-only in v1).
|
|
type FederationMeta struct {
|
|
RemotesQueried []string `json:"remotes_queried"`
|
|
RemotesFailed []RemoteFailure `json:"remotes_failed,omitempty"`
|
|
Degraded bool `json:"degraded"`
|
|
NamespaceRewrites []string `json:"namespace_rewrites,omitempty"`
|
|
Origins map[string]string `json:"origins,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// RemoteFailure records why a remote was not merged.
|
|
type RemoteFailure struct {
|
|
Slug string `json:"slug"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type remoteResult struct {
|
|
slug string
|
|
toolJSON []byte
|
|
}
|
|
|
|
// Augment runs AFTER the local tool result is in hand. It fans the same
|
|
// tool+args out to each enabled remote under a bounded deadline, merges
|
|
// the responses by per-tool shape, and returns the merged bytes carrying
|
|
// a sibling federation{} block + origins map. It NEVER blocks past the
|
|
// budget and never lets one remote's failure drop another's results or
|
|
// the local result.
|
|
func (f *Federator) Augment(ctx context.Context, tool string, body, localResult []byte, remotes []ServerEntry) []byte {
|
|
// Gate: only an allowlisted read tool with at least one enabled
|
|
// remote is federated. With no enabled remotes the local result is
|
|
// returned byte-for-byte — a pure-local install (or an all-disabled
|
|
// roster) is unaffected; the federation{} block + origins map are
|
|
// the additive superset that appears only when there is something to
|
|
// federate.
|
|
if !federationReadTools[tool] || MutatingTools[tool] || len(remotes) == 0 {
|
|
return localResult
|
|
}
|
|
|
|
localTool, wrapped := unwrapToolJSON(localResult)
|
|
|
|
results, meta := f.fanOut(ctx, tool, body, remotes)
|
|
|
|
merged, origins := f.merge(tool, localTool, results)
|
|
meta.Origins = origins
|
|
|
|
// Opt-in name-keyed fallback (OFF by default): a bare-name
|
|
// search on each remote, rendered in a SEPARATE name_hits section
|
|
// tagged text_matched — never merged into the primary id-keyed
|
|
// results (name hits have different native ids that ID-dedup cannot
|
|
// collapse). Rarity/length-gated so stdlib/builtin or too-short
|
|
// names don't surface plausible-but-wrong cross-repo hits.
|
|
if f.cfg.NameKeyedFallback && idKeyedTools[tool] {
|
|
if name := bareNameFromBody(body); nameEligible(name) {
|
|
if hits := f.nameKeyedFan(ctx, name, remotes); len(hits) > 0 {
|
|
merged = injectField(merged, "name_hits", hits)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(meta.RemotesFailed) > 0 && meta.Note == "" {
|
|
meta.Note = fmt.Sprintf("%d remote(s) did not answer; results are local%s.",
|
|
len(meta.RemotesFailed), remoteOnlyOrPartial(meta))
|
|
}
|
|
|
|
mergedWithMeta := attachFederation(merged, meta)
|
|
if !wrapped {
|
|
return mergedWithMeta
|
|
}
|
|
return rewrapToolJSON(localResult, mergedWithMeta)
|
|
}
|
|
|
|
func remoteOnlyOrPartial(meta FederationMeta) string {
|
|
if len(meta.RemotesQueried) > len(meta.RemotesFailed) {
|
|
return " plus the remotes that answered"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// fanOut queries each enabled remote concurrently under a per-remote
|
|
// deadline and a global budget. A plain WaitGroup (not errgroup) is used
|
|
// so one remote's error never cancels the others.
|
|
func (f *Federator) fanOut(ctx context.Context, tool string, body []byte, remotes []ServerEntry) ([]remoteResult, FederationMeta) {
|
|
meta := FederationMeta{RemotesQueried: []string{}}
|
|
if len(remotes) == 0 {
|
|
return nil, meta
|
|
}
|
|
budgetCtx, cancel := context.WithTimeout(ctx, f.cfg.Budget)
|
|
defer cancel()
|
|
|
|
var (
|
|
mu sync.Mutex
|
|
results []remoteResult
|
|
wg sync.WaitGroup
|
|
)
|
|
fail := func(slug, reason string) {
|
|
mu.Lock()
|
|
meta.RemotesFailed = append(meta.RemotesFailed, RemoteFailure{Slug: slug, Reason: reason})
|
|
meta.Degraded = true
|
|
mu.Unlock()
|
|
// Name the failing remote in the logs, not only the JSON block,
|
|
// so a degraded fan-out is visible to operators.
|
|
f.logger.Warn("federation: remote degraded",
|
|
zap.String("tool", tool),
|
|
zap.String("target_slug", slug),
|
|
zap.String("reason", reason))
|
|
}
|
|
|
|
audit := auditInfoFrom(ctx)
|
|
for _, rem := range remotes {
|
|
rem := rem
|
|
mu.Lock()
|
|
meta.RemotesQueried = append(meta.RemotesQueried, rem.Slug)
|
|
mu.Unlock()
|
|
// Audit every remote-routed fan-out call (cross-daemon access
|
|
// record), carrying the same {session_id, cwd, tool, target_slug}
|
|
// tuple as the single-remote proxy-routing audit line.
|
|
f.logger.Info("federation: remote-routed call",
|
|
zap.String("tool", tool),
|
|
zap.String("target_slug", rem.Slug),
|
|
zap.String("cwd", audit.Cwd),
|
|
zap.String("session_id", audit.SessionID),
|
|
zap.String("via", "fan-out"))
|
|
|
|
if f.breaker.isOpen(rem.Slug) {
|
|
fail(rem.Slug, "circuit_open")
|
|
continue
|
|
}
|
|
cli, err := f.clientFor(rem)
|
|
if err != nil {
|
|
fail(rem.Slug, "client_error")
|
|
continue
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
|
|
// Capability + readiness negotiation (cached), inside the
|
|
// goroutine so remotes negotiate concurrently. An
|
|
// incompatible major schema is refused; a still-warming
|
|
// remote is bucketed rather than counted as empty success.
|
|
if h, herr := f.health.get(budgetCtx, cli, f.cfg.PerRemoteTimeout); herr == nil {
|
|
if h.SchemaVersion != 0 && h.SchemaVersion != localSchemaMajor {
|
|
fail(rem.Slug, "incompatible_schema")
|
|
return
|
|
}
|
|
if !h.Indexed {
|
|
fail(rem.Slug, "warming")
|
|
return
|
|
}
|
|
}
|
|
|
|
rctx, rcancel := context.WithTimeout(budgetCtx, f.cfg.PerRemoteTimeout)
|
|
defer rcancel()
|
|
out, status, err := cli.ProxyToolCtx(rctx, tool, body)
|
|
if err != nil {
|
|
f.breaker.fail(rem.Slug)
|
|
fail(rem.Slug, "unreachable")
|
|
return
|
|
}
|
|
if status >= 400 {
|
|
f.breaker.fail(rem.Slug)
|
|
fail(rem.Slug, fmt.Sprintf("status_%d", status))
|
|
return
|
|
}
|
|
f.breaker.success(rem.Slug)
|
|
toolJSON, _ := unwrapToolJSON(out)
|
|
mu.Lock()
|
|
results = append(results, remoteResult{slug: rem.Slug, toolJSON: toolJSON})
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return results, meta
|
|
}
|
|
|
|
// merge dispatches to the per-tool adapter and returns the merged tool
|
|
// JSON plus the origins map.
|
|
func (f *Federator) merge(tool string, local []byte, remotes []remoteResult) ([]byte, map[string]string) {
|
|
switch tool {
|
|
case "find_usages", "get_callers", "get_call_chain", "get_dependents":
|
|
return mergeSubGraph(local, remotes)
|
|
case "search_symbols":
|
|
return mergeKeyedList(local, remotes, "results")
|
|
case "find_implementations":
|
|
return mergeKeyedList(local, remotes, "implementations")
|
|
case "smart_context":
|
|
return mergeSmartContext(local, remotes)
|
|
default:
|
|
return local, map[string]string{}
|
|
}
|
|
}
|
|
|
|
// mergeSubGraph merges query.SubGraph responses: nodes deduped by string
|
|
// ID (local wins), edges by (From,To,Kind). Origins keys each node ID to
|
|
// "local" or "remote:<slug>".
|
|
func mergeSubGraph(local []byte, remotes []remoteResult) ([]byte, map[string]string) {
|
|
origins := map[string]string{}
|
|
var sg query.SubGraph
|
|
if err := json.Unmarshal(local, &sg); err != nil {
|
|
return local, origins
|
|
}
|
|
seen := make(map[string]bool, len(sg.Nodes))
|
|
for _, n := range sg.Nodes {
|
|
if n != nil {
|
|
seen[n.ID] = true
|
|
origins[n.ID] = "local"
|
|
}
|
|
}
|
|
edgeSeen := make(map[string]bool, len(sg.Edges))
|
|
for _, e := range sg.Edges {
|
|
if e != nil {
|
|
edgeSeen[edgeKey(e)] = true
|
|
}
|
|
}
|
|
for _, rr := range remotes {
|
|
var rsg query.SubGraph
|
|
if err := json.Unmarshal(rr.toolJSON, &rsg); err != nil {
|
|
continue
|
|
}
|
|
for _, n := range rsg.Nodes {
|
|
if n == nil || seen[n.ID] {
|
|
continue // local wins on collision
|
|
}
|
|
seen[n.ID] = true
|
|
origins[n.ID] = "remote:" + rr.slug
|
|
sg.Nodes = append(sg.Nodes, n)
|
|
}
|
|
for _, e := range rsg.Edges {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
k := edgeKey(e)
|
|
if edgeSeen[k] {
|
|
continue
|
|
}
|
|
edgeSeen[k] = true
|
|
sg.Edges = append(sg.Edges, e)
|
|
}
|
|
}
|
|
sg.TotalNodes = len(sg.Nodes)
|
|
sg.TotalEdges = len(sg.Edges)
|
|
out, err := json.Marshal(sg)
|
|
if err != nil {
|
|
return local, origins
|
|
}
|
|
return out, origins
|
|
}
|
|
|
|
func edgeKey(e *graph.Edge) string {
|
|
return e.From + "\x00" + e.To + "\x00" + string(e.Kind)
|
|
}
|
|
|
|
// idKeyedTools are the SubGraph traversals whose primary query keys on a
|
|
// node id; only these get the optional bare-name fallback fan.
|
|
var idKeyedTools = map[string]bool{
|
|
"find_usages": true,
|
|
"get_callers": true,
|
|
"get_call_chain": true,
|
|
}
|
|
|
|
// commonNames are too-frequent identifiers a bare-name fan would
|
|
// mis-match across repos; the fallback skips them even when enabled.
|
|
var commonNames = map[string]bool{
|
|
"len": true, "set": true, "get": true, "new": true, "string": true,
|
|
"error": true, "close": true, "read": true, "write": true, "run": true,
|
|
"stop": true, "start": true, "init": true, "name": true, "value": true,
|
|
"key": true, "data": true, "result": true, "next": true, "size": true,
|
|
}
|
|
|
|
// bareNameFromBody extracts the symbol name from the {"arguments":{"id":...}}
|
|
// body, stripping the repo/file prefix and the :: separator.
|
|
func bareNameFromBody(body []byte) string {
|
|
var b struct {
|
|
Arguments struct {
|
|
ID string `json:"id"`
|
|
} `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(body, &b); err != nil {
|
|
return ""
|
|
}
|
|
id := b.Arguments.ID
|
|
if i := strings.LastIndex(id, "::"); i >= 0 {
|
|
id = id[i+2:]
|
|
}
|
|
return id
|
|
}
|
|
|
|
// nameEligible gates the bare-name fallback on rarity/length so a common
|
|
// or short identifier never fans out.
|
|
func nameEligible(name string) bool {
|
|
if len(name) < 4 {
|
|
return false
|
|
}
|
|
return !commonNames[strings.ToLower(name)]
|
|
}
|
|
|
|
// nameKeyedFan issues a per-remote search_symbols query for the bare
|
|
// name, capping each remote's contribution and tagging every hit with
|
|
// its origin + the text_matched confidence tier.
|
|
func (f *Federator) nameKeyedFan(ctx context.Context, name string, remotes []ServerEntry) []any {
|
|
const perRemoteCap = 5
|
|
body, _ := json.Marshal(map[string]any{
|
|
"arguments": map[string]any{"query": name, "limit": perRemoteCap, "format": "json"},
|
|
})
|
|
budgetCtx, cancel := context.WithTimeout(ctx, f.cfg.Budget)
|
|
defer cancel()
|
|
var (
|
|
mu sync.Mutex
|
|
hits []any
|
|
wg sync.WaitGroup
|
|
)
|
|
for _, rem := range remotes {
|
|
rem := rem
|
|
if f.breaker.isOpen(rem.Slug) {
|
|
continue
|
|
}
|
|
cli, err := f.clientFor(rem)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
rctx, rcancel := context.WithTimeout(budgetCtx, f.cfg.PerRemoteTimeout)
|
|
defer rcancel()
|
|
out, status, err := cli.ProxyToolCtx(rctx, "search_symbols", body)
|
|
if err != nil || status >= 400 {
|
|
return
|
|
}
|
|
tj, _ := unwrapToolJSON(out)
|
|
var rp map[string]any
|
|
if json.Unmarshal(tj, &rp) != nil {
|
|
return
|
|
}
|
|
results, _ := rp["results"].([]any)
|
|
mu.Lock()
|
|
for i, r := range results {
|
|
if i >= perRemoteCap {
|
|
break
|
|
}
|
|
if m, ok := r.(map[string]any); ok {
|
|
m["origin"] = "remote:" + rem.Slug
|
|
m["confidence"] = "text_matched"
|
|
hits = append(hits, m)
|
|
}
|
|
}
|
|
mu.Unlock()
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
return hits
|
|
}
|
|
|
|
// injectField adds a top-level key to a JSON object payload.
|
|
func injectField(b []byte, key string, value any) []byte {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
return b
|
|
}
|
|
m[key] = value
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return b
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergeKeyedList merges a {<key>:[{id,...}], total} payload (search_symbols
|
|
// results / find_implementations implementations): concat, dedup by native
|
|
// id (local wins), sum total.
|
|
func mergeKeyedList(local []byte, remotes []remoteResult, key string) ([]byte, map[string]string) {
|
|
origins := map[string]string{}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(local, &payload); err != nil {
|
|
return local, origins
|
|
}
|
|
items, _ := payload[key].([]any)
|
|
seen := map[string]bool{}
|
|
for _, it := range items {
|
|
if m, ok := it.(map[string]any); ok {
|
|
if id, ok := m["id"].(string); ok && id != "" {
|
|
seen[id] = true
|
|
origins[id] = "local"
|
|
}
|
|
}
|
|
}
|
|
total := toInt(payload["total"])
|
|
for _, rr := range remotes {
|
|
var rp map[string]any
|
|
if err := json.Unmarshal(rr.toolJSON, &rp); err != nil {
|
|
continue
|
|
}
|
|
ritems, _ := rp[key].([]any)
|
|
for _, it := range ritems {
|
|
m, ok := it.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
id, _ := m["id"].(string)
|
|
if id != "" && seen[id] {
|
|
continue
|
|
}
|
|
if id != "" {
|
|
seen[id] = true
|
|
origins[id] = "remote:" + rr.slug
|
|
}
|
|
items = append(items, m)
|
|
}
|
|
total += toInt(rp["total"])
|
|
}
|
|
payload[key] = items
|
|
payload["total"] = total
|
|
out, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return local, origins
|
|
}
|
|
return out, origins
|
|
}
|
|
|
|
// mergeSmartContext keeps the local manifest authoritative and folds each
|
|
// remote's contribution into a SEPARATE remote_context section under its
|
|
// slug. The edit_plan is NEVER federated (edits are local-only).
|
|
func mergeSmartContext(local []byte, remotes []remoteResult) ([]byte, map[string]string) {
|
|
origins := map[string]string{}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(local, &payload); err != nil {
|
|
return local, origins
|
|
}
|
|
var sections []any
|
|
for _, rr := range remotes {
|
|
var rp map[string]any
|
|
if err := json.Unmarshal(rr.toolJSON, &rp); err != nil {
|
|
continue
|
|
}
|
|
section := map[string]any{"slug": rr.slug}
|
|
// Carry the remote's relevant symbols / manifest, never its
|
|
// edit_plan.
|
|
for _, k := range []string{"relevant_symbols", "context_manifest", "symbols"} {
|
|
if v, ok := rp[k]; ok {
|
|
section[k] = v
|
|
}
|
|
}
|
|
sections = append(sections, section)
|
|
}
|
|
if len(sections) > 0 {
|
|
payload["remote_context"] = sections
|
|
}
|
|
delete(payload, "")
|
|
out, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return local, origins
|
|
}
|
|
return out, origins
|
|
}
|
|
|
|
func toInt(v any) int {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return int(n)
|
|
case int:
|
|
return n
|
|
case json.Number:
|
|
i, _ := n.Int64()
|
|
return int(i)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// attachFederation adds the federation{} block + origins map as siblings
|
|
// on a JSON-object tool response. A non-object payload is returned
|
|
// unchanged (no place to attach).
|
|
func attachFederation(toolJSON []byte, meta FederationMeta) []byte {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(toolJSON, &m); err != nil {
|
|
return toolJSON
|
|
}
|
|
fed := map[string]any{
|
|
"remotes_queried": meta.RemotesQueried,
|
|
"degraded": meta.Degraded,
|
|
}
|
|
if len(meta.RemotesFailed) > 0 {
|
|
fed["remotes_failed"] = meta.RemotesFailed
|
|
}
|
|
if len(meta.NamespaceRewrites) > 0 {
|
|
fed["namespace_rewrites"] = meta.NamespaceRewrites
|
|
}
|
|
if meta.Note != "" {
|
|
fed["note"] = meta.Note
|
|
}
|
|
m["federation"] = fed
|
|
if len(meta.Origins) > 0 {
|
|
m["origins"] = meta.Origins
|
|
}
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return toolJSON
|
|
}
|
|
return out
|
|
}
|
|
|
|
// unwrapToolJSON extracts the tool's JSON payload from the MCP result
|
|
// envelope ({content:[{type:text,text:<json>}]}). When the bytes are not
|
|
// that envelope, they are returned as-is with wrapped=false.
|
|
func unwrapToolJSON(b []byte) (toolJSON []byte, wrapped bool) {
|
|
var env struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(b, &env); err != nil || len(env.Content) == 0 {
|
|
return b, false
|
|
}
|
|
if env.Content[0].Type != "text" || env.Content[0].Text == "" {
|
|
return b, false
|
|
}
|
|
return []byte(env.Content[0].Text), true
|
|
}
|
|
|
|
// rewrapToolJSON replaces the text payload of an MCP result envelope with
|
|
// newToolJSON, preserving the envelope's other fields (e.g. is_error).
|
|
func rewrapToolJSON(envelope, newToolJSON []byte) []byte {
|
|
var m map[string]any
|
|
if err := json.Unmarshal(envelope, &m); err != nil {
|
|
return newToolJSON
|
|
}
|
|
content, ok := m["content"].([]any)
|
|
if !ok || len(content) == 0 {
|
|
return newToolJSON
|
|
}
|
|
first, ok := content[0].(map[string]any)
|
|
if !ok {
|
|
return newToolJSON
|
|
}
|
|
first["text"] = string(newToolJSON)
|
|
content[0] = first
|
|
m["content"] = content
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
return newToolJSON
|
|
}
|
|
return out
|
|
}
|