Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

634 lines
16 KiB
Go

package llm
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
)
// DaemonBackend speaks MCP over stdio to a long-running `gortex mcp`
// subprocess. The subprocess itself proxies to the running daemon
// (cross-repo state, watchers, snapshot) — so this backend's
// responses reflect the real graph the user has indexed.
//
// One subprocess per backend; requests are sequential (the agent
// loop only issues one tool call at a time). Concurrency is not
// supported; callers should hold one DaemonBackend per goroutine if
// they need it.
type DaemonBackend struct {
cmd *exec.Cmd
stdin io.WriteCloser
out *bufio.Reader
mu sync.Mutex
id int
}
// NewDaemonBackend spawns `gortex mcp` and performs the MCP handshake.
// gortexBin is the path/name of the gortex binary; empty falls back to
// "gortex" on PATH. extraEnv augments the subprocess environment.
//
// The subprocess is bound to the directory the calling process is in
// — make sure that directory is inside a workspace gortex knows about
// (or pass GORTEX_INDEX via extraEnv) or the handshake will fail.
func NewDaemonBackend(gortexBin string, extraEnv []string) (*DaemonBackend, error) {
if gortexBin == "" {
gortexBin = "gortex"
}
cmd := exec.Command(gortexBin, "mcp")
cmd.Env = append(os.Environ(), extraEnv...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("stdin pipe: %w", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("stderr pipe: %w", err)
}
// Drain stderr to /dev/null but keep it readable so the
// subprocess never blocks on a full pipe.
go func() { _, _ = io.Copy(io.Discard, stderr) }()
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start gortex mcp: %w", err)
}
b := &DaemonBackend{
cmd: cmd,
stdin: stdin,
out: bufio.NewReaderSize(stdout, 1<<20),
}
// MCP initialise handshake — initialize → initialized notification.
if _, err := b.call("initialize", map[string]any{
"protocolVersion": "2024-11-05",
"capabilities": map[string]any{},
"clientInfo": map[string]any{"name": "gortex-llm", "version": "0.1"},
}); err != nil {
_ = b.Close()
return nil, fmt.Errorf("mcp initialize: %w", err)
}
if err := b.notify("notifications/initialized", map[string]any{}); err != nil {
_ = b.Close()
return nil, fmt.Errorf("mcp initialized notification: %w", err)
}
return b, nil
}
// Close terminates the subprocess. Safe to call more than once.
func (b *DaemonBackend) Close() error {
if b == nil || b.cmd == nil || b.cmd.Process == nil {
return nil
}
_ = b.stdin.Close()
_ = b.cmd.Process.Kill()
_, _ = b.cmd.Process.Wait()
b.cmd = nil
return nil
}
type jsonrpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
type jsonrpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
// call sends a JSON-RPC request and waits for a matching response.
// Skips notifications and server-initiated requests on the wire.
func (b *DaemonBackend) call(method string, params any) (json.RawMessage, error) {
b.mu.Lock()
defer b.mu.Unlock()
b.id++
id := b.id
req := jsonrpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
buf, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal %s: %w", method, err)
}
buf = append(buf, '\n')
if _, err := b.stdin.Write(buf); err != nil {
return nil, fmt.Errorf("write %s: %w", method, err)
}
for {
line, err := b.out.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read %s: %w", method, err)
}
var resp jsonrpcResponse
if err := json.Unmarshal(line, &resp); err != nil {
// Non-JSON line (banner?) — ignore.
continue
}
if len(resp.ID) == 0 {
// Notification or server request — skip.
continue
}
var gotID int
if err := json.Unmarshal(resp.ID, &gotID); err != nil {
// Non-numeric id (server request) — skip.
continue
}
if gotID != id {
continue
}
if resp.Error != nil {
return nil, fmt.Errorf("%s: %s (code %d)", method, resp.Error.Message, resp.Error.Code)
}
return resp.Result, nil
}
}
// notify sends a JSON-RPC notification (no id, no response expected).
func (b *DaemonBackend) notify(method string, params any) error {
b.mu.Lock()
defer b.mu.Unlock()
req := jsonrpcRequest{JSONRPC: "2.0", Method: method, Params: params}
buf, err := json.Marshal(req)
if err != nil {
return err
}
buf = append(buf, '\n')
_, err = b.stdin.Write(buf)
return err
}
// callTool invokes one MCP tool by name and returns the text content
// of the first content block (gortex tools always emit one).
func (b *DaemonBackend) callTool(ctx context.Context, name string, args map[string]any) ([]byte, error) {
_ = ctx // not honored for cancellation in this minimal stdio path
args["format"] = "json"
raw, err := b.call("tools/call", map[string]any{
"name": name,
"arguments": args,
})
if err != nil {
return nil, err
}
var result struct {
IsError bool `json:"isError"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("%s: parse envelope: %w", name, err)
}
if result.IsError {
var msg string
if len(result.Content) > 0 {
msg = result.Content[0].Text
}
return nil, fmt.Errorf("%s: tool error: %s", name, msg)
}
if len(result.Content) == 0 {
return nil, nil
}
return []byte(result.Content[0].Text), nil
}
func (b *DaemonBackend) SearchSymbols(ctx context.Context, query string, scope Scope, limit int) ([]Match, error) {
args := map[string]any{"query": query}
if limit > 0 {
args["limit"] = limit
}
applyScope(args, scope)
raw, err := b.callTool(ctx, "search_symbols", args)
if err != nil {
return nil, err
}
// gortex search_symbols json: {"results":[{node…}], …} OR
// {"nodes":[…]} OR (older) a top-level array of nodes.
var wrap struct {
Results []rawGraphNode `json:"results"`
Nodes []rawGraphNode `json:"nodes"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && (wrap.Results != nil || wrap.Nodes != nil) {
src := wrap.Results
if len(src) == 0 {
src = wrap.Nodes
}
out := make([]Match, len(src))
for i, r := range src {
out[i] = r.match()
}
return out, nil
}
var arr []rawGraphNode
if err := json.Unmarshal(raw, &arr); err == nil {
out := make([]Match, len(arr))
for i, r := range arr {
out[i] = r.match()
}
return out, nil
}
return nil, fmt.Errorf("search_symbols: unrecognised payload shape: %s", truncate(string(raw), 200))
}
// rawGraphNode is the gortex graph-tool node payload. Field names
// follow the canonical gortex shape (file_path, repo_prefix, start_line)
// with fallbacks to older variants (path, file, repo, line) and a
// nested meta sub-doc some versions wrap things in.
type rawGraphNode struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
FilePath string `json:"file_path"`
Path string `json:"path"`
File string `json:"file"`
RepoPrefix string `json:"repo_prefix"`
Repo string `json:"repo"`
StartLine int `json:"start_line"`
Line int `json:"line"`
Meta struct {
FilePath string `json:"file_path"`
Path string `json:"path"`
File string `json:"file"`
RepoPrefix string `json:"repo_prefix"`
Repo string `json:"repo"`
} `json:"meta"`
}
func (n rawGraphNode) file() string {
for _, s := range []string{n.FilePath, n.Path, n.File, n.Meta.FilePath, n.Meta.Path, n.Meta.File} {
if s != "" {
return s
}
}
return ""
}
func (n rawGraphNode) repo() string {
for _, s := range []string{n.RepoPrefix, n.Repo, n.Meta.RepoPrefix, n.Meta.Repo} {
if s != "" {
return s
}
}
return ""
}
func (n rawGraphNode) caller() Caller {
return Caller{ID: n.ID, File: n.file(), Repo: n.repo()}
}
func (n rawGraphNode) match() Match {
line := n.StartLine
if line == 0 {
line = n.Line
}
return Match{
ID: n.ID,
Name: n.Name,
Kind: n.Kind,
Path: n.file(),
Line: line,
Repo: n.repo(),
}
}
type rawGraphEdge struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
}
func (b *DaemonBackend) GetCallers(ctx context.Context, id string, scope Scope, limit int) ([]Caller, error) {
args := map[string]any{"id": id}
if limit > 0 {
args["limit"] = limit
}
applyScope(args, scope)
raw, err := b.callTool(ctx, "get_callers", args)
if err != nil {
return nil, err
}
// Canonical gortex graph-tool payload: { "nodes":[...], "edges":[...],
// "total_nodes":N, "total_edges":M, ...}. Each edge has from→to;
// callers are the nodes that originate edges pointing to `id`.
var wrap struct {
Nodes []rawGraphNode `json:"nodes"`
Edges []rawGraphEdge `json:"edges"`
// legacy shapes
Callers []rawGraphNode `json:"callers"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, fmt.Errorf("get_callers: parse: %w", err)
}
// Build node lookup for the from→Caller rendering step.
byID := make(map[string]rawGraphNode, len(wrap.Nodes))
for _, n := range wrap.Nodes {
byID[n.ID] = n
}
var callerIDs []string
if len(wrap.Edges) > 0 {
// Edges from the get_callers tool point caller→callee; the
// callee is the queried `id`, the caller is `from`.
seen := make(map[string]struct{})
for _, e := range wrap.Edges {
if e.To != id && e.From == id {
// Some tool versions invert the direction; fall back.
if _, ok := seen[e.To]; ok {
continue
}
seen[e.To] = struct{}{}
callerIDs = append(callerIDs, e.To)
continue
}
if e.To != id {
continue
}
if _, ok := seen[e.From]; ok {
continue
}
seen[e.From] = struct{}{}
callerIDs = append(callerIDs, e.From)
}
} else if len(wrap.Callers) > 0 {
out := make([]Caller, len(wrap.Callers))
for i, n := range wrap.Callers {
out[i] = n.caller()
}
return out, nil
} else {
// No edges payload AND no legacy callers — return every node
// in the response except the queried one. Conservative
// fallback for tool versions that just list neighbours.
for _, n := range wrap.Nodes {
if n.ID == id {
continue
}
callerIDs = append(callerIDs, n.ID)
}
}
out := make([]Caller, 0, len(callerIDs))
for _, cid := range callerIDs {
if n, ok := byID[cid]; ok {
out = append(out, n.caller())
continue
}
out = append(out, Caller{ID: cid})
}
return out, nil
}
func (b *DaemonBackend) GetDependencies(ctx context.Context, id string, scope Scope, depth, limit int) ([]Dep, error) {
args := map[string]any{"id": id}
if depth > 0 {
args["depth"] = depth
}
if limit > 0 {
args["limit"] = limit
}
applyScope(args, scope)
raw, err := b.callTool(ctx, "get_dependencies", args)
if err != nil {
return nil, err
}
// Canonical shape: { "nodes":[…], "edges":[{from,to,kind}], … }
// Edges from the queried `id` to other nodes describe dependencies.
var wrap struct {
Nodes []rawGraphNode `json:"nodes"`
Edges []rawGraphEdge `json:"edges"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, fmt.Errorf("get_dependencies: parse: %w", err)
}
byID := make(map[string]rawGraphNode, len(wrap.Nodes))
for _, n := range wrap.Nodes {
byID[n.ID] = n
}
var depIDs []struct {
ID string
Kind string
}
seen := map[string]struct{}{}
for _, e := range wrap.Edges {
if e.From != id {
continue
}
if _, ok := seen[e.To]; ok {
continue
}
seen[e.To] = struct{}{}
depIDs = append(depIDs, struct {
ID string
Kind string
}{e.To, e.Kind})
}
// If the graph payload omitted explicit edges, fall back to
// every non-self node as an opaque dependency.
if len(depIDs) == 0 {
for _, n := range wrap.Nodes {
if n.ID == id {
continue
}
depIDs = append(depIDs, struct {
ID string
Kind string
}{n.ID, ""})
}
}
out := make([]Dep, 0, len(depIDs))
for _, d := range depIDs {
n := byID[d.ID]
out = append(out, Dep{ID: d.ID, Kind: d.Kind, File: n.file(), Repo: n.repo()})
}
return out, nil
}
// rawContract mirrors a single contract entry in gortex's nested
// by_repo / by_type response shape. Field names match the canonical
// node-style (file_path, repo_prefix) with method/path lifted out of
// the meta sub-doc for HTTP contracts.
type rawContract struct {
ID string `json:"id"`
Type string `json:"type"`
Role string `json:"role"`
RepoPrefix string `json:"repo_prefix"`
Repo string `json:"repo"`
FilePath string `json:"file_path"`
File string `json:"file"`
Line int `json:"line"`
SymbolID string `json:"symbol_id"`
Method string `json:"method"`
Path string `json:"path"`
Meta struct {
Method string `json:"method"`
Path string `json:"path"`
} `json:"meta"`
}
func (r rawContract) toContract() Contract {
method := r.Method
if method == "" {
method = r.Meta.Method
}
path := r.Path
if path == "" {
path = r.Meta.Path
}
// id format for HTTP is "http::METHOD::/path" — fall back to
// parsing the id when method/path are absent.
if (method == "" || path == "") && strings.HasPrefix(r.ID, "http::") {
parts := strings.SplitN(r.ID, "::", 3)
if len(parts) == 3 {
if method == "" {
method = parts[1]
}
if path == "" {
path = parts[2]
}
}
}
repo := r.RepoPrefix
if repo == "" {
repo = r.Repo
}
file := r.FilePath
if file == "" {
file = r.File
}
return Contract{
Type: r.Type, Role: r.Role, Repo: repo,
Method: method, Path: path,
File: file, Line: r.Line,
ID: r.ID, SymbolID: r.SymbolID,
}
}
func (b *DaemonBackend) ListContracts(ctx context.Context, f ContractFilter, scope Scope) ([]Contract, error) {
args := map[string]any{"action": "list"}
if f.Role != "" {
args["role"] = f.Role
}
if f.Type != "" {
args["type"] = f.Type
}
if f.Limit > 0 {
args["limit"] = f.Limit
}
// Chain tracing inherently spans repos. Disable the daemon's
// active-project auto-scope unless the caller pinned a specific
// repo.
if scope.Repo == "" && scope.Project == "" {
args["all_repos"] = true
}
applyScope(args, scope)
raw, err := b.callTool(ctx, "contracts", args)
if err != nil {
return nil, err
}
// gortex contracts payload (canonical, json format):
// { "by_repo": { "<repo>": { "contracts": { "<type>": [contract…] }, "total": N } } }
// Flatten the nested structure into a single slice, then apply
// client-side method / path filters.
var wrap struct {
ByRepo map[string]struct {
Contracts map[string][]rawContract `json:"contracts"`
} `json:"by_repo"`
// Legacy / non-grouped variant.
Contracts []rawContract `json:"contracts"`
}
if err := json.Unmarshal(raw, &wrap); err != nil {
return nil, fmt.Errorf("contracts: parse: %w", err)
}
var out []Contract
add := func(rc rawContract) {
c := rc.toContract()
if f.Method != "" && c.Method != f.Method {
return
}
if f.Path != "" && c.Path != f.Path {
return
}
out = append(out, c)
}
for _, repo := range wrap.ByRepo {
for _, list := range repo.Contracts {
for _, rc := range list {
add(rc)
}
}
}
for _, rc := range wrap.Contracts {
add(rc)
}
return out, nil
}
func (b *DaemonBackend) ListRepos(ctx context.Context) ([]Repo, error) {
raw, err := b.callTool(ctx, "list_repos", map[string]any{})
if err != nil {
return nil, err
}
// list_repos shape varies a bit too. Look for "repos" key, fall
// back to top-level array.
var wrap struct {
Repos []struct {
Name string `json:"name"`
Root string `json:"root"`
Path string `json:"path"`
Nodes int `json:"nodes"`
} `json:"repos"`
}
if err := json.Unmarshal(raw, &wrap); err != nil || wrap.Repos == nil {
return nil, fmt.Errorf("list_repos: unrecognised payload shape: %s", truncate(string(raw), 200))
}
out := make([]Repo, len(wrap.Repos))
for i, r := range wrap.Repos {
root := r.Root
if root == "" {
root = r.Path
}
out[i] = Repo{Name: r.Name, Root: root, Nodes: r.Nodes}
}
return out, nil
}
func applyScope(args map[string]any, s Scope) {
if s.Repo != "" {
args["repo"] = s.Repo
}
if s.Project != "" {
args["project"] = s.Project
}
if s.Ref != "" {
args["ref"] = s.Ref
}
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
var _ Backend = (*DaemonBackend)(nil)