Add go-llm v2: redesigned API for simpler LLM abstraction
v2 is a new Go module (v2/) with a dramatically simpler API: - Unified Message type (no more Input marker interface) - Define[T] for ergonomic tool creation with standard context.Context - Chat session with automatic tool-call loop (agent loop) - Streaming via pull-based StreamReader - MCP one-call connect (MCPStdioServer, MCPHTTPServer, MCPSSEServer) - Middleware support (logging, retry, timeout, usage tracking) - Decoupled JSON Schema (map[string]any, no provider coupling) - Sample tools: WebSearch, Browser, Exec, ReadFile, WriteFile, HTTP - Providers: OpenAI, Anthropic, Google (all with streaming) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
59
v2/tools/browser.go
Normal file
59
v2/tools/browser.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// BrowserParams defines parameters for the browser tool.
|
||||
type BrowserParams struct {
|
||||
URL string `json:"url" description:"The URL to fetch and extract text from"`
|
||||
}
|
||||
|
||||
// Browser creates a simple web content fetcher tool.
|
||||
// It fetches a URL and returns the text content.
|
||||
//
|
||||
// For a full headless browser, consider using an MCP server like Playwright MCP.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(tools.Browser())
|
||||
func Browser() llm.Tool {
|
||||
return llm.Define[BrowserParams]("browser", "Fetch a web page and return its text content",
|
||||
func(ctx context.Context, p BrowserParams) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.URL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "go-llm/2.0 (Web Fetcher)")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fetching URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Limit to 1MB
|
||||
limited := io.LimitReader(resp.Body, 1<<20)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading body: %w", err)
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"url": p.URL,
|
||||
"status": resp.StatusCode,
|
||||
"content_type": resp.Header.Get("Content-Type"),
|
||||
"body": string(body),
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(out), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
101
v2/tools/exec.go
Normal file
101
v2/tools/exec.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// ExecParams defines parameters for the exec tool.
|
||||
type ExecParams struct {
|
||||
Command string `json:"command" description:"The shell command to execute"`
|
||||
}
|
||||
|
||||
// ExecOption configures the exec tool.
|
||||
type ExecOption func(*execConfig)
|
||||
|
||||
type execConfig struct {
|
||||
allowedCommands []string
|
||||
workDir string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// WithAllowedCommands restricts which commands can be executed.
|
||||
// If empty, all commands are allowed.
|
||||
func WithAllowedCommands(cmds []string) ExecOption {
|
||||
return func(c *execConfig) { c.allowedCommands = cmds }
|
||||
}
|
||||
|
||||
// WithWorkDir sets the working directory for command execution.
|
||||
func WithWorkDir(dir string) ExecOption {
|
||||
return func(c *execConfig) { c.workDir = dir }
|
||||
}
|
||||
|
||||
// WithExecTimeout sets the maximum execution time.
|
||||
func WithExecTimeout(d time.Duration) ExecOption {
|
||||
return func(c *execConfig) { c.timeout = d }
|
||||
}
|
||||
|
||||
// Exec creates a shell command execution tool.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(
|
||||
// tools.Exec(tools.WithAllowedCommands([]string{"ls", "cat", "grep"})),
|
||||
// )
|
||||
func Exec(opts ...ExecOption) llm.Tool {
|
||||
cfg := &execConfig{
|
||||
timeout: 30 * time.Second,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
return llm.Define[ExecParams]("exec", "Execute a shell command and return its output",
|
||||
func(ctx context.Context, p ExecParams) (string, error) {
|
||||
// Check allowed commands
|
||||
if len(cfg.allowedCommands) > 0 {
|
||||
parts := strings.Fields(p.Command)
|
||||
if len(parts) == 0 {
|
||||
return "", fmt.Errorf("empty command")
|
||||
}
|
||||
allowed := false
|
||||
for _, cmd := range cfg.allowedCommands {
|
||||
if parts[0] == cmd {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", fmt.Errorf("command %q is not in the allowed list", parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, cfg.timeout)
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.CommandContext(ctx, "cmd", "/C", p.Command)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", p.Command)
|
||||
}
|
||||
|
||||
if cfg.workDir != "" {
|
||||
cmd.Dir = cfg.workDir
|
||||
}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Sprintf("Error: %s\nOutput: %s", err.Error(), string(output)), nil
|
||||
}
|
||||
|
||||
return string(output), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
75
v2/tools/http.go
Normal file
75
v2/tools/http.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// HTTPParams defines parameters for the HTTP request tool.
|
||||
type HTTPParams struct {
|
||||
Method string `json:"method" description:"HTTP method" enum:"GET,POST,PUT,DELETE,PATCH,HEAD"`
|
||||
URL string `json:"url" description:"Request URL"`
|
||||
Headers map[string]string `json:"headers,omitempty" description:"Request headers"`
|
||||
Body *string `json:"body,omitempty" description:"Request body"`
|
||||
}
|
||||
|
||||
// HTTP creates an HTTP request tool.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(tools.HTTP())
|
||||
func HTTP() llm.Tool {
|
||||
return llm.Define[HTTPParams]("http_request", "Make an HTTP request and return the response",
|
||||
func(ctx context.Context, p HTTPParams) (string, error) {
|
||||
var bodyReader io.Reader
|
||||
if p.Body != nil {
|
||||
bodyReader = bytes.NewBufferString(*p.Body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, p.Method, p.URL, bodyReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range p.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Limit to 1MB
|
||||
limited := io.LimitReader(resp.Body, 1<<20)
|
||||
body, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
headers := map[string]string{}
|
||||
for k, v := range resp.Header {
|
||||
if len(v) > 0 {
|
||||
headers[k] = v[0]
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"status_text": resp.Status,
|
||||
"headers": headers,
|
||||
"body": string(body),
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(out), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
81
v2/tools/readfile.go
Normal file
81
v2/tools/readfile.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// ReadFileParams defines parameters for the read file tool.
|
||||
type ReadFileParams struct {
|
||||
Path string `json:"path" description:"File path to read"`
|
||||
Start *int `json:"start,omitempty" description:"Starting line number (1-based, inclusive)"`
|
||||
End *int `json:"end,omitempty" description:"Ending line number (1-based, inclusive)"`
|
||||
}
|
||||
|
||||
// ReadFile creates a file reading tool.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(tools.ReadFile())
|
||||
func ReadFile() llm.Tool {
|
||||
return llm.Define[ReadFileParams]("read_file", "Read the contents of a file",
|
||||
func(ctx context.Context, p ReadFileParams) (string, error) {
|
||||
f, err := os.Open(p.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opening file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// If no line range specified, read the whole file (limited to 1MB)
|
||||
if p.Start == nil && p.End == nil {
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
if info.Size() > 1<<20 {
|
||||
return "", fmt.Errorf("file too large (%d bytes), use start/end to read a range", info.Size())
|
||||
}
|
||||
data, err := os.ReadFile(p.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Read specific line range
|
||||
start := 1
|
||||
end := -1
|
||||
if p.Start != nil {
|
||||
start = *p.Start
|
||||
}
|
||||
if p.End != nil {
|
||||
end = *p.End
|
||||
}
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
lineNum := 0
|
||||
for scanner.Scan() {
|
||||
lineNum++
|
||||
if lineNum < start {
|
||||
continue
|
||||
}
|
||||
if end > 0 && lineNum > end {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d: %s", lineNum, scanner.Text()))
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("scanning file: %w", err)
|
||||
}
|
||||
|
||||
return strings.Join(lines, "\n"), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
101
v2/tools/websearch.go
Normal file
101
v2/tools/websearch.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Package tools provides ready-to-use tool implementations for common agent patterns.
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// WebSearchParams defines parameters for the web search tool.
|
||||
type WebSearchParams struct {
|
||||
Query string `json:"query" description:"The search query"`
|
||||
Count *int `json:"count,omitempty" description:"Number of results to return (default 5, max 20)"`
|
||||
}
|
||||
|
||||
// WebSearch creates a web search tool using the Brave Search API.
|
||||
//
|
||||
// Get a free API key at https://brave.com/search/api/
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(tools.WebSearch("your-brave-api-key"))
|
||||
func WebSearch(apiKey string) llm.Tool {
|
||||
return llm.Define[WebSearchParams]("web_search", "Search the web for information using Brave Search",
|
||||
func(ctx context.Context, p WebSearchParams) (string, error) {
|
||||
count := 5
|
||||
if p.Count != nil && *p.Count > 0 {
|
||||
count = *p.Count
|
||||
if count > 20 {
|
||||
count = 20
|
||||
}
|
||||
}
|
||||
|
||||
u := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
|
||||
url.QueryEscape(p.Query), count)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Subscription-Token", apiKey)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("search API returned %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Parse and simplify the response
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
var results []result
|
||||
if web, ok := raw["web"].(map[string]any); ok {
|
||||
if items, ok := web["results"].([]any); ok {
|
||||
for _, item := range items {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
r := result{}
|
||||
if t, ok := m["title"].(string); ok {
|
||||
r.Title = t
|
||||
}
|
||||
if u, ok := m["url"].(string); ok {
|
||||
r.URL = u
|
||||
}
|
||||
if d, ok := m["description"].(string); ok {
|
||||
r.Description = d
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(results, "", " ")
|
||||
return string(out), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
31
v2/tools/writefile.go
Normal file
31
v2/tools/writefile.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
llm "gitea.stevedudenhoeffer.com/steve/go-llm/v2"
|
||||
)
|
||||
|
||||
// WriteFileParams defines parameters for the write file tool.
|
||||
type WriteFileParams struct {
|
||||
Path string `json:"path" description:"File path to write"`
|
||||
Content string `json:"content" description:"Content to write to the file"`
|
||||
}
|
||||
|
||||
// WriteFile creates a file writing tool.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// tools := llm.NewToolBox(tools.WriteFile())
|
||||
func WriteFile() llm.Tool {
|
||||
return llm.Define[WriteFileParams]("write_file", "Write content to a file (creates or overwrites)",
|
||||
func(ctx context.Context, p WriteFileParams) (string, error) {
|
||||
if err := os.WriteFile(p.Path, []byte(p.Content), 0644); err != nil {
|
||||
return "", fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("Successfully wrote %d bytes to %s", len(p.Content), p.Path), nil
|
||||
},
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user