/ internal / tools / server.go
server.go
  1  package tools
  2  
  3  import (
  4  	"context"
  5  	"encoding/json"
  6  	"fmt"
  7  	"strings"
  8  
  9  	"github.com/Kocoro-lab/ShanClaw/internal/agent"
 10  	"github.com/Kocoro-lab/ShanClaw/internal/client"
 11  )
 12  
 13  // ServerTool wraps a server-side tool schema and proxies execution
 14  // through the gateway's /api/v1/tools/{name}/execute endpoint.
 15  type ServerTool struct {
 16  	schema  client.ServerToolSchema
 17  	gateway *client.GatewayClient
 18  }
 19  
 20  func NewServerTool(schema client.ServerToolSchema, gateway *client.GatewayClient) *ServerTool {
 21  	return &ServerTool{schema: schema, gateway: gateway}
 22  }
 23  
 24  func (t *ServerTool) Info() agent.ToolInfo {
 25  	return agent.ToolInfo{
 26  		Name:        t.schema.Name,
 27  		Description: t.schema.Description,
 28  		Parameters:  t.schema.Parameters,
 29  	}
 30  }
 31  
 32  func (t *ServerTool) Run(ctx context.Context, argsJSON string) (agent.ToolResult, error) {
 33  	var args map[string]any
 34  	if argsJSON != "" && argsJSON != "{}" {
 35  		if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
 36  			return agent.ToolResult{
 37  				Content: fmt.Sprintf("invalid arguments: %v", err),
 38  				IsError: true,
 39  			}, nil
 40  		}
 41  	}
 42  	if args == nil {
 43  		args = map[string]any{}
 44  	}
 45  
 46  	resp, err := t.gateway.ExecuteTool(ctx, t.schema.Name, args, "")
 47  	if err != nil {
 48  		msg := err.Error()
 49  		prefix := classifyServerError(msg)
 50  		return agent.ToolResult{
 51  			Content: fmt.Sprintf("%sserver tool error: %v", prefix, err),
 52  			IsError: true,
 53  		}, nil
 54  	}
 55  
 56  	// Convert server-reported usage (xAI Grok tokens for x_search, SerpAPI
 57  	// queries for web_search, etc.) into an agent-level ToolUsage. Populated
 58  	// on the ToolResult so the audit logger can attribute cost per call; also
 59  	// emitted via context so the per-run usage accumulator picks it up.
 60  	// Server populates resp.Usage when the underlying provider returns billing
 61  	// info; older servers leave it nil and this is a no-op.
 62  	var toolUsage *agent.ToolUsage
 63  	if resp.Usage != nil {
 64  		u := resp.Usage
 65  		// The gateway currently returns a flat `tokens` count (synthetic for
 66  		// SERP tools, real input+output sum for x_search). If explicit
 67  		// input/output breakdowns are present, prefer them; else collapse
 68  		// `tokens` into TotalTokens so the accumulator still sees the volume.
 69  		totalTokens := u.TotalTokens
 70  		if totalTokens == 0 {
 71  			totalTokens = u.Tokens
 72  		}
 73  		if totalTokens == 0 {
 74  			totalTokens = u.InputTokens + u.OutputTokens
 75  		}
 76  		model := u.Model
 77  		if model == "" {
 78  			model = u.CostModel
 79  		}
 80  		toolUsage = &agent.ToolUsage{
 81  			Provider:     u.Provider,
 82  			Model:        model,
 83  			InputTokens:  u.InputTokens,
 84  			OutputTokens: u.OutputTokens,
 85  			TotalTokens:  totalTokens,
 86  			CostUSD:      u.CostUSD,
 87  			Units:        u.Units,
 88  			UnitType:     u.UnitType,
 89  		}
 90  		agent.EmitUsage(ctx, agent.TurnUsage{
 91  			InputTokens:  u.InputTokens,
 92  			OutputTokens: u.OutputTokens,
 93  			TotalTokens:  totalTokens,
 94  			CostUSD:      u.CostUSD,
 95  			// Gateway tool calls are not LLM calls from the driving model's
 96  			// perspective — leave LLMCalls=0 so session LLMCalls stays clean.
 97  			Model: model,
 98  		})
 99  	}
100  
101  	if resp.Error != nil && *resp.Error != "" {
102  		return agent.ToolResult{Content: *resp.Error, IsError: true, Usage: toolUsage}, nil
103  	}
104  
105  	if !resp.Success {
106  		return agent.ToolResult{Content: "tool execution failed", IsError: true, Usage: toolUsage}, nil
107  	}
108  
109  	// Prefer pre-formatted text from backend; fall back to raw JSON output
110  	if resp.Text != nil && *resp.Text != "" {
111  		return agent.ToolResult{Content: *resp.Text, Usage: toolUsage}, nil
112  	}
113  	if len(resp.Output) == 0 || string(resp.Output) == "null" {
114  		return agent.ToolResult{Content: "no output", Usage: toolUsage}, nil
115  	}
116  	return agent.ToolResult{Content: string(resp.Output), Usage: toolUsage}, nil
117  }
118  
119  // RequiresApproval returns false — the server enforces its own access control.
120  func (t *ServerTool) RequiresApproval() bool { return false }
121  
122  // classifyServerError returns the appropriate error prefix based on the error
123  // message, so the agent loop's error-handling instructions can guide the model
124  // to retry transient failures instead of fabricating explanations.
125  //
126  // Status-code markers (returned NNN) are checked before free-text transient
127  // keywords so that a 4xx response body mentioning "timeout" (e.g. validation
128  // "timeout must be <= 30") is not mis-tagged as transient and retried.
129  func classifyServerError(msg string) string {
130  	lower := strings.ToLower(msg)
131  	// Status-code classification first — the HTTP status is authoritative.
132  	if strings.Contains(lower, "returned 401") || strings.Contains(lower, "returned 403") {
133  		return "[permission error] "
134  	}
135  	if strings.Contains(lower, "returned 400") || strings.Contains(lower, "returned 422") {
136  		return "[validation error] "
137  	}
138  	if strings.Contains(lower, "returned 429") ||
139  		strings.Contains(lower, "returned 502") ||
140  		strings.Contains(lower, "returned 503") ||
141  		strings.Contains(lower, "returned 504") {
142  		return "[transient error] "
143  	}
144  	// Keyword fallback for network-layer failures that have no HTTP status
145  	// (connection refused/reset, DNS, timeouts before the server responded).
146  	for _, sig := range []string{
147  		"rate limit", "timeout", "timed out", "connection refused",
148  		"connection reset", "eof", "unavailable",
149  	} {
150  		if strings.Contains(lower, sig) {
151  			return "[transient error] "
152  		}
153  	}
154  	return ""
155  }
156  
157  // ToolSource implements agent.ToolSourcer for deterministic tool ordering.
158  func (t *ServerTool) ToolSource() agent.ToolSource { return agent.SourceGateway }