├── .gitignore ├── handlers ├── error.go ├── summarize_test.go ├── summarizer │ └── option.go ├── option.go ├── noop.go ├── noop_test.go ├── sequence.go ├── summarize.go ├── range.go ├── if.go ├── policy.go ├── for.go ├── structured_extractor.go ├── first.go ├── mocks_test.go ├── freeform_extractor_test.go ├── freeform_extractor.go └── switch.go ├── go.mod ├── providers ├── openai │ ├── README.md │ ├── go.mod │ ├── utils.go │ ├── tokenizer.go │ ├── handler.go │ ├── response.go │ ├── summarize_test.go │ ├── go.sum │ ├── options.go │ ├── handler_test.go │ └── provider_test.go └── gemini │ ├── tokenizer.go │ ├── utils.go │ ├── go.mod │ ├── options.go │ ├── handler.go │ ├── response.go │ ├── schema.go │ ├── handler_test.go │ └── provider_test.go ├── internal └── utils │ └── sha256.go ├── providers.go ├── tools ├── go.mod ├── human │ └── console.go ├── calculator │ ├── calculator.go │ ├── calculator_starlark_test.go │ ├── calculator_lua_test.go │ └── calculator_lua.go ├── extensions │ ├── lua_condition_test.go │ └── lua_condition.go ├── go.sum ├── prompt.go └── serpapi │ └── serpapi.go ├── go.sum ├── message.go ├── response.go ├── _examples ├── chat-basic-ask │ └── main.go ├── chat-completion-provider │ └── main.go ├── handlers-switch │ └── main.go ├── handlers-sequence │ └── main.go ├── go.mod ├── tools-calculator │ └── main.go ├── chat-structured-output │ └── main.go ├── middleware-ratelimit │ └── main.go ├── function-calling │ └── main.go ├── handlers-threadflow │ └── main.go ├── function-registry │ └── main.go ├── data-extraction │ └── main.go └── handlers-must │ └── main.go ├── request.go ├── Makefile ├── tool.go ├── messages.go ├── middleware ├── retry │ └── option.go ├── retry.go └── logging.go ├── handler.go ├── prompt.go ├── metadata.go ├── context_test.go ├── context.go ├── function.go └── CONTRIBUTING.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .env 3 | .vscode -------------------------------------------------------------------------------- /handlers/error.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | // TODO: handlers that can take error results 4 | -------------------------------------------------------------------------------- /handlers/summarize_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | // Summarize tests are in the minds/providers/openai package because they require 4 | // an OpenAI API key to run. 5 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chriscow/minds 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/google/uuid v1.6.0 7 | github.com/matryer/is v1.4.1 8 | gopkg.in/yaml.v2 v2.4.0 9 | ) 10 | -------------------------------------------------------------------------------- /handlers/summarizer/option.go: -------------------------------------------------------------------------------- 1 | package summarizer 2 | 3 | type Option func(*Options) 4 | 5 | type Options struct { 6 | Prompt string 7 | } 8 | 9 | func WithPrompt(prompt string) Option { 10 | return func(o *Options) { 11 | o.Prompt = prompt 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /providers/openai/README.md: -------------------------------------------------------------------------------- 1 | # OpenAI ContentGenerator / ThreadHandler 2 | 3 | This is a concrete implementation of the minds.ContentGenerator and 4 | minds.ThreadHandler interfaces with OpenAI. 5 | 6 | The client implementation is separate than the interface implementation to 7 | allow for easy swapping of the client implementation for testing. -------------------------------------------------------------------------------- /internal/utils/sha256.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | ) 8 | 9 | func SHA256Hash(data []byte) (string, error) { 10 | h := sha256.New() 11 | _, err := h.Write(data) 12 | if err != nil { 13 | return "", fmt.Errorf("write data: %w", err) 14 | } 15 | 16 | return hex.EncodeToString(h.Sum(nil)), nil 17 | } 18 | -------------------------------------------------------------------------------- /providers/gemini/tokenizer.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/generative-ai-go/genai" 7 | ) 8 | 9 | // Implement the TokenCounter interface for the Gemini provider. 10 | func (p *Provider) CountTokens(text string) (int, error) { 11 | ctx := context.Background() 12 | model, err := p.getModel() 13 | if err != nil { 14 | return 0, err 15 | } 16 | tokResp, err := model.CountTokens(ctx, genai.Text(text)) 17 | if err != nil { 18 | return 0, nil 19 | } 20 | 21 | return int(tokResp.TotalTokens), nil 22 | } 23 | -------------------------------------------------------------------------------- /providers/openai/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chriscow/minds/providers/openai 2 | 3 | go 1.18 4 | 5 | // replace github.com/chriscow/minds => ../../ 6 | 7 | require ( 8 | github.com/chriscow/minds v0.0.5 9 | github.com/hashicorp/go-retryablehttp v0.7.7 10 | github.com/matryer/is v1.4.1 11 | github.com/sashabaranov/go-openai v1.36.0 12 | github.com/tiktoken-go/tokenizer v0.2.1 13 | ) 14 | 15 | require ( 16 | github.com/dlclark/regexp2 v1.9.0 // indirect 17 | github.com/google/uuid v1.6.0 // indirect 18 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 19 | gopkg.in/yaml.v2 v2.4.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /providers/openai/utils.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | ) 9 | 10 | func Ask(ctx context.Context, question string, opts ...Option) (string, error) { 11 | llm, err := NewProvider(opts...) 12 | if err != nil { 13 | return "", fmt.Errorf("new provider: %w", err) 14 | } 15 | 16 | resp, err := llm.GenerateContent(ctx, minds.Request{ 17 | Messages: minds.Messages{{Role: minds.RoleUser, Content: question}}}) 18 | if err != nil { 19 | return "", fmt.Errorf("generate content: %w", err) 20 | } 21 | 22 | return resp.String(), nil 23 | 24 | } 25 | -------------------------------------------------------------------------------- /providers/gemini/utils.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | ) 9 | 10 | func Ask(ctx context.Context, prompt string, opts ...Option) (string, error) { 11 | llm, err := NewProvider(ctx, opts...) 12 | if err != nil { 13 | return "", fmt.Errorf("failed to create LLM provider: %v", err) 14 | } 15 | 16 | resp, err := llm.GenerateContent(ctx, minds.Request{ 17 | Messages: minds.Messages{{Content: prompt}}}) 18 | 19 | if err != nil { 20 | return "", fmt.Errorf("failed to generate content: %v", err) 21 | } 22 | 23 | return resp.String(), err 24 | } 25 | -------------------------------------------------------------------------------- /providers.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import "context" 4 | 5 | type ContentGenerator interface { 6 | ModelName() string 7 | GenerateContent(context.Context, Request) (Response, error) 8 | Close() 9 | } 10 | 11 | type Embedder interface { 12 | CreateEmbeddings(model string, input []string) ([][]float32, error) 13 | } 14 | 15 | type KVStore interface { 16 | Save(ctx context.Context, key []byte, value []byte) error 17 | Load(ctx context.Context, key []byte) ([]byte, error) 18 | } 19 | 20 | // TokenCounter defines how to count tokens for different models 21 | type TokenCounter interface { 22 | CountTokens(text string) (int, error) 23 | } 24 | -------------------------------------------------------------------------------- /tools/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chriscow/minds/tools 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.4 6 | 7 | replace ( 8 | github.com/chriscow/minds => ../ 9 | github.com/chriscow/minds/providers/openai => ../providers/openai 10 | ) 11 | 12 | require ( 13 | github.com/chriscow/minds v0.0.7 14 | github.com/matryer/is v1.4.1 15 | github.com/sashabaranov/go-openai v1.36.1 16 | github.com/yuin/gopher-lua v1.1.1 17 | go.starlark.net v0.0.0-20241125201518-c05ff208a98f 18 | gopkg.in/yaml.v2 v2.4.0 19 | ) 20 | 21 | require ( 22 | github.com/google/uuid v1.6.0 // indirect 23 | golang.org/x/sys v0.24.0 // indirect 24 | google.golang.org/protobuf v1.34.2 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /handlers/option.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import "github.com/chriscow/minds" 4 | 5 | type HandlerOption struct { 6 | name string 7 | description string 8 | prompt minds.Prompt 9 | // handler minds.ThreadHandler 10 | } 11 | 12 | func WithName(name string) func(*HandlerOption) { 13 | return func(ho *HandlerOption) { 14 | ho.name = name 15 | } 16 | } 17 | 18 | func WithDescription(description string) func(*HandlerOption) { 19 | return func(ho *HandlerOption) { 20 | ho.description = description 21 | } 22 | } 23 | 24 | func WithPrompt(prompt minds.Prompt) func(*HandlerOption) { 25 | return func(ho *HandlerOption) { 26 | ho.prompt = prompt 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 2 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 3 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 4 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 5 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 6 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 7 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 8 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 9 | -------------------------------------------------------------------------------- /providers/openai/tokenizer.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "github.com/chriscow/minds" 5 | 6 | "github.com/tiktoken-go/tokenizer" 7 | ) 8 | 9 | // Tokenizer implements the TokenCounter interface for the OpenAI provider. 10 | type Tokenizer struct { 11 | codec tokenizer.Codec 12 | } 13 | 14 | func NewTokenizer(modelName string) (minds.TokenCounter, error) { 15 | codec, err := tokenizer.ForModel(tokenizer.Model(modelName)) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return Tokenizer{codec: codec}, nil 21 | } 22 | 23 | func (t Tokenizer) CountTokens(text string) (int, error) { 24 | ids, _, err := t.codec.Encode(text) 25 | if err != nil { 26 | return 0, err 27 | } 28 | 29 | return len(ids), nil 30 | } 31 | -------------------------------------------------------------------------------- /tools/human/console.go: -------------------------------------------------------------------------------- 1 | package human 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/chriscow/minds" 9 | ) 10 | 11 | type Input struct { 12 | Question string `json:"question" description:"The question for the human to answer"` 13 | } 14 | 15 | func NewConsoleInput() (minds.Tool, error) { 16 | return minds.WrapFunction( 17 | "human", 18 | `Useful for getting input from a human. This tool can ask a human a question and returns the answer.`, 19 | Input{}, 20 | human_console, 21 | ) 22 | } 23 | 24 | func human_console(_ context.Context, args []byte) ([]byte, error) { 25 | var params Input 26 | if err := json.Unmarshal(args, ¶ms); err != nil { 27 | return nil, err 28 | } 29 | 30 | var answer string 31 | 32 | fmt.Printf("\n\n%s > ", params.Question) 33 | fmt.Scanln(&answer) 34 | 35 | return []byte(answer), nil 36 | } 37 | -------------------------------------------------------------------------------- /tools/calculator/calculator.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | type Syntax string 10 | 11 | const ( 12 | Starlark Syntax = "starlark" 13 | Lua Syntax = "lua" 14 | ) 15 | 16 | func NewCalculator(syntax Syntax) (minds.Tool, error) { 17 | var fn minds.CallableFunc 18 | var runtime string 19 | switch syntax { 20 | case Starlark: 21 | fn = withStarlark 22 | runtime = "starlark" 23 | case Lua: 24 | fn = withLua 25 | runtime = "lua" 26 | default: 27 | return nil, fmt.Errorf("unsupported syntax: %s", syntax) 28 | } 29 | 30 | return minds.WrapFunction( 31 | "calculator", 32 | fmt.Sprintf(`Useful for getting the result of a math expression. The input to this 33 | tool should be a valid mathematical expression that could be executed by 34 | a %s evaluator.`, runtime), 35 | struct { 36 | Input string `json:"input" description:"The mathematical expression to evaluate"` 37 | }{}, 38 | fn, 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /message.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "encoding/json" 5 | ) 6 | 7 | type Role string 8 | 9 | const ( 10 | RoleUser Role = "user" 11 | RoleAssistant Role = "assistant" 12 | RoleSystem Role = "system" 13 | RoleFunction Role = "function" 14 | RoleTool Role = "tool" 15 | RoleAI Role = "ai" 16 | RoleModel Role = "model" 17 | RoleDeveloper Role = "developer" 18 | ) 19 | 20 | type Message struct { 21 | Role Role `json:"role"` 22 | Content string `json:"content"` 23 | Name string `json:"name,omitempty"` // For function calls 24 | Metadata Metadata `json:"metadata,omitempty"` // For additional context 25 | ToolCallID string `json:"tool_call_id,omitempty"` 26 | ToolCalls []ToolCall `json:"func_response,omitempty"` 27 | } 28 | 29 | func (m Message) TokenCount(tokenizer TokenCounter) (int, error) { 30 | wholeMsg, err := json.Marshal(m) 31 | if err != nil { 32 | return 0, err 33 | } 34 | 35 | return tokenizer.CountTokens(string(wholeMsg)) 36 | } 37 | -------------------------------------------------------------------------------- /response.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | // ResponseType indicates what kind of response we received 4 | type ResponseType int 5 | 6 | const ( 7 | ResponseTypeUnknown ResponseType = iota 8 | ResponseTypeText 9 | ResponseTypeToolCall 10 | ) 11 | 12 | type ResponseSchema struct { 13 | Name string `json:"name"` 14 | Description string `json:"description"` 15 | Definition Definition `json:"schema"` 16 | } 17 | 18 | func NewResponseSchema(name, desc string, v any) (*ResponseSchema, error) { 19 | def, err := GenerateSchema(v) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &ResponseSchema{ 25 | Name: name, 26 | Description: desc, 27 | Definition: *def, 28 | }, nil 29 | } 30 | 31 | type Response interface { 32 | // String returns a string representation of the response 33 | String() string 34 | 35 | // ToolCall returns the tool call details if this is a tool call response. 36 | ToolCalls() []ToolCall 37 | } 38 | 39 | type ResponseHandler func(resp Response) error 40 | 41 | func (h ResponseHandler) HandleResponse(resp Response) error { 42 | return h(resp) 43 | } 44 | -------------------------------------------------------------------------------- /handlers/noop.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/chriscow/minds" 5 | ) 6 | 7 | // noop implements a handler that performs no operation and simply passes through 8 | // the thread context unchanged. It's useful as a default handler in conditional 9 | // flows or as a placeholder in handler chains. 10 | type noop struct{} 11 | 12 | // Noop creates a new no-operation handler that simply returns the thread context 13 | // unchanged. It's useful as a default handler in Switch or If handlers when no 14 | // action is desired for the default case. 15 | // 16 | // Example: 17 | // 18 | // // Use as default in Switch 19 | // sw := Switch("type-switch", Noop(), cases...) 20 | // 21 | // // Use as default in If 22 | // ih := If("type-check", condition, trueHandler, Noop()) 23 | func Noop() *noop { 24 | return &noop{} 25 | } 26 | 27 | // String returns the handler name for debugging and logging purposes. 28 | func (n *noop) String() string { 29 | return "Noop" 30 | } 31 | 32 | // HandleThread implements the ThreadHandler interface by returning the thread 33 | // context unchanged. If a next handler is provided, it will be called with 34 | // the unchanged context. 35 | func (n *noop) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 36 | if next != nil { 37 | return next.HandleThread(tc, nil) 38 | } 39 | return tc, nil 40 | } 41 | -------------------------------------------------------------------------------- /_examples/chat-basic-ask/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds/providers/gemini" 8 | "github.com/chriscow/minds/providers/openai" 9 | "github.com/fatih/color" 10 | ) 11 | 12 | var ( 13 | cyan = color.New(color.FgCyan).SprintFunc() 14 | green = color.New(color.FgGreen).SprintFunc() 15 | ) 16 | 17 | // Sometimes you just need to ask a quick question without a lot of boilerplate. 18 | // This example demonstrates how to use the Gemini provider to ask a simple question 19 | // using the default model (gemini-1.5-flash). 20 | // 21 | // Ask always uses the fastest and usually cheapest model available, which is 22 | // gemini-1.5-flash in this case. 23 | // 24 | // You need to have the provider's API key set in the environment. 25 | func main() { 26 | ctx := context.Background() 27 | prompt := "Knock knock" 28 | 29 | askGemini(ctx, prompt) 30 | askOpenAI(ctx, prompt) 31 | } 32 | 33 | func askGemini(ctx context.Context, prompt string) { 34 | answer, err := gemini.Ask(ctx, prompt) 35 | if err != nil { 36 | answer = "error: " + err.Error() 37 | } 38 | fmt.Printf("%s: %s", cyan("Gemini"), answer) 39 | } 40 | 41 | func askOpenAI(ctx context.Context, prompt string) { 42 | answer, err := openai.Ask(ctx, prompt) 43 | if err != nil { 44 | answer = "error: " + err.Error() 45 | } 46 | fmt.Printf("%s: %s\n", green("OpenAI"), answer) 47 | } 48 | -------------------------------------------------------------------------------- /providers/openai/handler.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // HandleMessage implements the ThreadHandler interface for the OpenAI provider. 10 | func (p *Provider) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 11 | 12 | messages := minds.Messages{} 13 | if p.options.systemPrompt != nil { 14 | messages = append(messages, minds.Message{ 15 | Role: minds.RoleSystem, 16 | Content: *p.options.systemPrompt, 17 | }) 18 | } 19 | 20 | messages = append(messages, tc.Messages()...) 21 | 22 | req := minds.Request{ 23 | Messages: messages, 24 | } 25 | 26 | for i, m := range req.Messages { 27 | switch m.Role { 28 | case minds.RoleModel: 29 | req.Messages[i].Role = minds.RoleAssistant 30 | } 31 | } 32 | 33 | resp, err := p.GenerateContent(tc.Context(), req) 34 | if err != nil { 35 | return tc, fmt.Errorf("failed to generate content: %w", err) 36 | } 37 | // fmt.Printf("[%s] %s\n", p.options.name, resp.String()) 38 | 39 | msg := minds.Message{ 40 | Role: minds.RoleAssistant, 41 | Name: p.options.name, 42 | Content: resp.String(), 43 | } 44 | 45 | tc.AppendMessages(msg) 46 | 47 | if next != nil { 48 | return next.HandleThread(tc, nil) 49 | } 50 | 51 | return tc, nil 52 | } 53 | 54 | func (p *Provider) String() string { 55 | return fmt.Sprintf("OpenAI Provider: %s", p.options.name) 56 | } 57 | -------------------------------------------------------------------------------- /request.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | type RequestOptions struct { 4 | ModelName *string 5 | Temperature *float32 6 | MaxOutputTokens *int 7 | ResponseSchema *ResponseSchema 8 | ToolRegistry ToolRegistry 9 | ToolChoice string 10 | } 11 | 12 | type RequestOption func(*RequestOptions) 13 | 14 | type Request struct { 15 | Options RequestOptions 16 | Messages Messages `json:"messages"` 17 | } 18 | 19 | func NewRequest(messages Messages, opts ...RequestOption) Request { 20 | options := RequestOptions{} 21 | for _, opt := range opts { 22 | opt(&options) 23 | } 24 | 25 | return Request{ 26 | Options: options, 27 | Messages: messages, 28 | } 29 | } 30 | 31 | func WithModel(model string) RequestOption { 32 | return func(o *RequestOptions) { 33 | o.ModelName = &model 34 | } 35 | } 36 | 37 | func WithTemperature(temperature float32) RequestOption { 38 | return func(o *RequestOptions) { 39 | o.Temperature = &temperature 40 | } 41 | } 42 | 43 | func WithMaxOutputTokens(tokens int) RequestOption { 44 | return func(o *RequestOptions) { 45 | o.MaxOutputTokens = &tokens 46 | } 47 | } 48 | 49 | func WithResponseSchema(schema ResponseSchema) RequestOption { 50 | return func(o *RequestOptions) { 51 | o.ResponseSchema = &schema 52 | } 53 | } 54 | 55 | func (r Request) TokenCount(tokenizer TokenCounter) (int, error) { 56 | total := 0 57 | for _, msg := range r.Messages { 58 | count, err := tokenizer.CountTokens(msg.Content) 59 | if err != nil { 60 | return 0, err 61 | } 62 | total += count 63 | } 64 | 65 | return total, nil 66 | } 67 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # marked .PHONY to avoid conflicts with files or directories of the same name. 2 | .PHONY: up test test_v test_short test_race test_stress test_codecov test_reconnect build fmt wait run-examples 3 | 4 | # Variables 5 | EXAMPLES_DIR := ./_examples 6 | EXAMPLES := $(wildcard $(EXAMPLES_DIR)/*) 7 | 8 | # Default target: run all examples 9 | .PHONY: run-examples 10 | run-examples: 11 | @echo "Running all examples..." 12 | @for example in $(EXAMPLES); do \ 13 | if [ -f $$example/main.go ]; then \ 14 | echo "Running $$example"; \ 15 | (cd $$example && go run .); \ 16 | fi; \ 17 | done 18 | 19 | .PHONY: help 20 | help: 21 | @echo "Available targets:" 22 | @echo " test - Run all tests" 23 | @echo " test_v - Run all tests with verbose output" 24 | @echo " test_short - Run short tests" 25 | @echo " test_race - Run tests with race detection" 26 | @echo " test_codecov - Run tests with coverage profiling" 27 | @echo " build - Build the project" 28 | @echo " fmt - Format the code with gofmt and goimports" 29 | @echo " run-examples - Run all examples in $(EXAMPLES_DIR)" 30 | 31 | # add -p to parallelize tests 32 | test: 33 | go test -p=1 ./... 34 | 35 | test_v: 36 | go test -v -p=1 ./... 37 | 38 | test_short: 39 | go test ./... -short 40 | 41 | test_race: 42 | go test ./... -short -race 43 | 44 | # test_stress: 45 | # go test -tags=stress -timeout=30m ./... 46 | 47 | test_codecov: 48 | go test -coverprofile=coverage.out -covermode=atomic ./... 49 | 50 | # test_reconnect: 51 | # go test -tags=reconnect ./... 52 | 53 | build: 54 | go build ./... 55 | 56 | fmt: 57 | go fmt ./... 58 | goimports -l -w . 59 | 60 | -------------------------------------------------------------------------------- /tools/extensions/lua_condition_test.go: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/matryer/is" 9 | ) 10 | 11 | func TestLuaCondition(t *testing.T) { 12 | is := is.New(t) 13 | 14 | t.Run("simple boolean return", func(t *testing.T) { 15 | is := is.New(t) 16 | 17 | cond := LuaCondition{ 18 | Script: "return true", 19 | } 20 | 21 | tc := minds.NewThreadContext(context.Background()) 22 | 23 | result, err := cond.Evaluate(tc) 24 | is.NoErr(err) 25 | is.True(result) 26 | }) 27 | 28 | t.Run("complex logic with message content", func(t *testing.T) { 29 | is := is.New(t) 30 | 31 | cond := LuaCondition{ 32 | Script: ` 33 | local msg = last_message 34 | return string.match(msg, "important") ~= nil 35 | `, 36 | } 37 | tc := minds.NewThreadContext(context.Background()). 38 | WithMessages(minds.Message{ 39 | Role: minds.RoleUser, Content: "This is not important", 40 | }) 41 | 42 | result, err := cond.Evaluate(tc) 43 | is.NoErr(err) 44 | is.True(result) 45 | }) 46 | 47 | t.Run("invalid script", func(t *testing.T) { 48 | is := is.New(t) 49 | 50 | cond := LuaCondition{ 51 | Script: "invalid lua code", 52 | } 53 | 54 | tc := minds.NewThreadContext(context.Background()) 55 | 56 | _, err := cond.Evaluate(tc) 57 | is.True(err != nil) // Should return an error 58 | }) 59 | 60 | t.Run("non-boolean return", func(t *testing.T) { 61 | is := is.New(t) 62 | 63 | cond := LuaCondition{ 64 | Script: "return 'string'", 65 | } 66 | 67 | tc := minds.NewThreadContext(context.Background()) 68 | _, err := cond.Evaluate(tc) 69 | is.True(err != nil) // Should return an error 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /tools/calculator/calculator_starlark_test.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/matryer/is" 8 | ) 9 | 10 | func TestCalculator(t *testing.T) { 11 | is := is.New(t) 12 | 13 | t.Run("basic python addition", func(t *testing.T) { 14 | calc, _ := NewCalculator(Starlark) 15 | result, err := calc.Call(context.Background(), []byte(`{"input":"1 + 2"}`)) 16 | is.NoErr(err) 17 | is.Equal(string(result), "3") 18 | }) 19 | 20 | t.Run("basic python subtraction", func(t *testing.T) { 21 | calc, _ := NewCalculator(Starlark) 22 | result, err := calc.Call(context.Background(), []byte(`{"input":"2 - 1"}`)) 23 | is.NoErr(err) 24 | is.Equal(string(result), "1") 25 | }) 26 | 27 | t.Run("basic python multiplication", func(t *testing.T) { 28 | calc, _ := NewCalculator(Starlark) 29 | result, err := calc.Call(context.Background(), []byte(`{"input":"2 * 3"}`)) 30 | is.NoErr(err) 31 | is.Equal(string(result), "6") 32 | }) 33 | 34 | t.Run("basic python division", func(t *testing.T) { 35 | calc, _ := NewCalculator(Starlark) 36 | result, err := calc.Call(context.Background(), []byte(`{"input":"6 / 2"}`)) 37 | is.NoErr(err) 38 | is.Equal(string(result), "3.0") 39 | }) 40 | 41 | t.Run("basic python modulo", func(t *testing.T) { 42 | calc, _ := NewCalculator(Starlark) 43 | result, err := calc.Call(context.Background(), []byte(`{"input":"7 % 3"}`)) 44 | is.NoErr(err) 45 | is.Equal(string(result), "1") 46 | }) 47 | 48 | t.Run("basic python exponentiation", func(t *testing.T) { 49 | calc, _ := NewCalculator(Starlark) 50 | script := `{"input":"math.sqrt(16) + math.pow(2, 3)"}` 51 | result, err := calc.Call(context.Background(), []byte(script)) 52 | is.NoErr(err) 53 | is.Equal(string(result), "12.0") 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /tool.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | type ToolType string 9 | 10 | const ( 11 | ToolTypeFunction ToolType = "function" 12 | ) 13 | 14 | type ToolCall struct { 15 | ID string `json:"id,omitempty"` 16 | Type string `json:"type,omitempty"` 17 | Function FunctionCall `json:"function,omitempty"` 18 | } 19 | 20 | // Tool is an interface for a tool that can be executed by an LLM. It is similar 21 | // to a function in that it takes input and produces output, but it can be more 22 | // complex than a simple function and doesn't require a wrapper. 23 | type Tool interface { 24 | Type() string 25 | Name() string 26 | Description() string 27 | Parameters() Definition 28 | Call(context.Context, []byte) ([]byte, error) 29 | } 30 | 31 | type ToolRegistry interface { 32 | // Register adds a new function to the registry 33 | Register(t Tool) error 34 | // Lookup retrieves a function by name 35 | Lookup(name string) (Tool, bool) 36 | // List returns all registered functions 37 | List() []Tool 38 | } 39 | 40 | func NewToolRegistry() ToolRegistry { 41 | return &toolRegistry{ 42 | tools: make(map[string]Tool), 43 | } 44 | } 45 | 46 | type toolRegistry struct { 47 | tools map[string]Tool 48 | } 49 | 50 | func (t *toolRegistry) Register(tool Tool) error { 51 | if _, exists := t.tools[tool.Name()]; exists { 52 | return fmt.Errorf("tool %s already registered", tool.Name()) 53 | } 54 | t.tools[tool.Name()] = tool 55 | return nil 56 | } 57 | 58 | func (t *toolRegistry) Lookup(name string) (Tool, bool) { 59 | tool, ok := t.tools[name] 60 | return tool, ok 61 | } 62 | 63 | func (t *toolRegistry) List() []Tool { 64 | tools := make([]Tool, 0, len(t.tools)) 65 | for _, tool := range t.tools { 66 | tools = append(tools, tool) 67 | } 68 | return tools 69 | } 70 | -------------------------------------------------------------------------------- /providers/openai/response.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | 9 | "github.com/sashabaranov/go-openai" 10 | ) 11 | 12 | type Response struct { 13 | raw openai.ChatCompletionResponse 14 | calls []minds.ToolCall 15 | } 16 | 17 | func NewResponse(resp openai.ChatCompletionResponse, calls []minds.ToolCall) (*Response, error) { 18 | if len(resp.Choices) == 0 { 19 | return nil, fmt.Errorf("no response from OpenAI") 20 | } 21 | 22 | if len(resp.Choices) > 1 { 23 | return nil, fmt.Errorf("multiple choices in OpenAI response not supported") 24 | } 25 | 26 | if resp.Choices[0].FinishReason != openai.FinishReasonStop && resp.Choices[0].FinishReason != openai.FinishReasonToolCalls { 27 | return nil, errors.New(string(resp.Choices[0].FinishReason)) 28 | } 29 | 30 | if calls == nil { 31 | calls = make([]minds.ToolCall, 0) 32 | } 33 | 34 | return &Response{raw: resp, calls: calls}, nil 35 | } 36 | 37 | func (r Response) Messages() (minds.Messages, error) { 38 | messages := minds.Messages{} 39 | 40 | if len(r.raw.Choices) == 0 { 41 | return messages, nil 42 | } 43 | 44 | for _, call := range r.ToolCalls() { 45 | messages = append(messages, minds.Message{ 46 | Role: minds.RoleTool, 47 | Content: string(call.Function.Result), 48 | ToolCallID: call.ID, 49 | }) 50 | } 51 | 52 | if r.raw.Choices[0].Message.Content != "" { 53 | messages = append(messages, minds.Message{ 54 | Role: minds.RoleAssistant, 55 | Content: r.raw.Choices[0].Message.Content, 56 | }) 57 | } 58 | 59 | return messages, nil 60 | } 61 | 62 | func (r Response) String() string { 63 | if len(r.raw.Choices) == 0 { 64 | return "No response from OpenAI" 65 | } 66 | 67 | return r.raw.Choices[0].Message.Content 68 | } 69 | 70 | func (r Response) ToolCalls() []minds.ToolCall { 71 | return r.calls 72 | } 73 | -------------------------------------------------------------------------------- /_examples/chat-completion-provider/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/chriscow/minds" 9 | "github.com/chriscow/minds/providers/gemini" 10 | "github.com/chriscow/minds/providers/openai" 11 | "github.com/fatih/color" 12 | ) 13 | 14 | const prompt = `Hello, how are you?` 15 | 16 | var ( 17 | cyan = color.New(color.FgCyan).SprintFunc() 18 | green = color.New(color.FgGreen).SprintFunc() 19 | purple = color.New(color.FgHiMagenta).SprintFunc() 20 | ) 21 | 22 | func main() { 23 | req := minds.Request{Messages: minds.Messages{{Role: minds.RoleUser, Content: prompt}}} 24 | ctx := context.Background() 25 | withGemini(ctx, req) 26 | withOpenAI(ctx, req) 27 | withDeepSeek(ctx, req) 28 | } 29 | 30 | func withGemini(ctx context.Context, req minds.Request) { 31 | llm, err := gemini.NewProvider(ctx) 32 | if err != nil { 33 | fmt.Printf("[%s] error: %v", cyan("Gemini"), err) 34 | return 35 | } 36 | resp, _ := llm.GenerateContent(ctx, req) 37 | fmt.Printf("[%s] %s", cyan("Gemini"), resp.String()) 38 | } 39 | 40 | func withOpenAI(ctx context.Context, req minds.Request) { 41 | llm, err := openai.NewProvider() 42 | if err != nil { 43 | fmt.Printf("[%s] error: %v", green("OpenAI"), err) 44 | return 45 | } 46 | resp, _ := llm.GenerateContent(ctx, req) 47 | fmt.Printf("[%s] %s\n", green("OpenAI"), resp.String()) 48 | } 49 | 50 | func withDeepSeek(ctx context.Context, req minds.Request) { 51 | baseURl := "https://api.deepseek.com" 52 | apiKey := os.Getenv("DEEPSEEK_API_KEY") 53 | model := "deepseek-chat" 54 | llm, err := openai.NewProvider( 55 | openai.WithAPIKey(apiKey), 56 | openai.WithModel(model), 57 | openai.WithBaseURL(baseURl), 58 | ) 59 | if err != nil { 60 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 61 | return 62 | } 63 | resp, _ := llm.GenerateContent(ctx, req) 64 | fmt.Printf("[%s] %s\n", purple("DeepSeek"), resp.String()) 65 | } 66 | -------------------------------------------------------------------------------- /tools/go.sum: -------------------------------------------------------------------------------- 1 | github.com/chriscow/minds v0.0.7 h1:IU5NvT8g5czWg9qLjc+Rx0cnf17x4xx+uLJRVlD5s5M= 2 | github.com/chriscow/minds v0.0.7/go.mod h1:iznO9umfPFv30TzYbhmhSk5h1LiVPsNw//LX46AUH2k= 3 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 4 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 5 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 6 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 7 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 8 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 9 | github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g= 10 | github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 11 | github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= 12 | github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= 13 | go.starlark.net v0.0.0-20241125201518-c05ff208a98f h1:W+3pcCdjGognUT+oE6tXsC3xiCEcCYTaJBXHHRn7aW0= 14 | go.starlark.net v0.0.0-20241125201518-c05ff208a98f/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= 15 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 16 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 17 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 18 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 19 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 20 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 21 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 22 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 23 | -------------------------------------------------------------------------------- /messages.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ErrNoMessages = errors.New("no messages in thread") 9 | ) 10 | 11 | type Messages []Message 12 | 13 | // Copy returns a deep copy of the messages 14 | func (m Messages) Copy() Messages { 15 | copied := make(Messages, len(m)) 16 | for i, msg := range m { 17 | newMsg := Message{ 18 | Role: msg.Role, 19 | Content: msg.Content, 20 | Name: msg.Name, 21 | Metadata: msg.Metadata.Copy(), 22 | ToolCallID: msg.ToolCallID, 23 | ToolCalls: msg.ToolCalls, 24 | } 25 | copied[i] = newMsg 26 | } 27 | return copied 28 | } 29 | 30 | // Last returns the last message in the slice of messages. NOTE: This will 31 | // return an empty message if there are no messages in the slice. 32 | func (m Messages) Last() Message { 33 | if len(m) == 0 { 34 | return Message{} 35 | } 36 | 37 | return m[len(m)-1] 38 | } 39 | 40 | // Exclude returns a new slice of Message with messages with the specified roles removed 41 | func (m Messages) Exclude(roles ...Role) Messages { 42 | filteredMsgs := Messages{} 43 | 44 | for _, msg := range m { 45 | keep := true 46 | for _, role := range roles { 47 | if msg.Role == role { 48 | keep = false 49 | break 50 | } 51 | } 52 | 53 | if keep { 54 | filteredMsgs = append(filteredMsgs, msg) 55 | } 56 | } 57 | 58 | return filteredMsgs 59 | } 60 | 61 | func (m Messages) Only(roles ...Role) Messages { 62 | filteredMsgs := Messages{} 63 | 64 | for _, msg := range m { 65 | for _, role := range roles { 66 | if msg.Role == role { 67 | filteredMsgs = append(filteredMsgs, msg) 68 | break 69 | } 70 | } 71 | } 72 | 73 | return filteredMsgs 74 | } 75 | 76 | func (m Messages) TokenCount(tokenizer TokenCounter) (int, error) { 77 | total := 0 78 | for _, msg := range m { 79 | count, err := tokenizer.CountTokens(msg.Content) 80 | if err != nil { 81 | return 0, err 82 | } 83 | 84 | total += count 85 | } 86 | 87 | return total, nil 88 | } 89 | -------------------------------------------------------------------------------- /handlers/noop_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/chriscow/minds/handlers" 9 | "github.com/matryer/is" 10 | ) 11 | 12 | func TestNoop(t *testing.T) { 13 | is := is.New(t) 14 | 15 | t.Run("returns unmodified context without next handler", func(t *testing.T) { 16 | is := is.New(t) 17 | 18 | noop := handlers.Noop() 19 | tc := minds.NewThreadContext(context.Background()). 20 | WithMetadata(minds.Metadata{"key": "value"}) 21 | 22 | result, err := noop.HandleThread(tc, nil) 23 | is.NoErr(err) 24 | is.Equal(result, tc) // Should return unmodified context 25 | }) 26 | 27 | t.Run("calls next handler if provided", func(t *testing.T) { 28 | is := is.New(t) 29 | 30 | next := &mockHandler{name: "next"} 31 | noop := handlers.Noop() 32 | tc := minds.NewThreadContext(context.Background()) 33 | 34 | _, err := noop.HandleThread(tc, next) 35 | is.NoErr(err) 36 | is.True(next.Called()) // Next handler should be called 37 | }) 38 | 39 | t.Run("propagates next handler's response", func(t *testing.T) { 40 | is := is.New(t) 41 | 42 | expectedContext := minds.NewThreadContext(context.Background()). 43 | WithMetadata(minds.Metadata{"modified": true}) 44 | 45 | next := &mockHandler{ 46 | name: "next", 47 | tcResult: expectedContext, 48 | } 49 | 50 | noop := handlers.Noop() 51 | tc := minds.NewThreadContext(context.Background()) 52 | 53 | result, err := noop.HandleThread(tc, next) 54 | is.NoErr(err) 55 | is.Equal(result, expectedContext) // Should return next handler's response 56 | }) 57 | 58 | t.Run("propagates next handler's error", func(t *testing.T) { 59 | is := is.New(t) 60 | 61 | next := &mockHandler{ 62 | name: "next", 63 | expectedErr: context.DeadlineExceeded, 64 | } 65 | 66 | noop := handlers.Noop() 67 | tc := minds.NewThreadContext(context.Background()) 68 | 69 | _, err := noop.HandleThread(tc, next) 70 | is.Equal(err, next.expectedErr) // Should propagate next handler's error 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /middleware/retry/option.go: -------------------------------------------------------------------------------- 1 | package retry 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // Option defines a retry configuration function. 10 | type Option func(*Options) 11 | 12 | // BackoffStrategy defines how delay increases between attempts. 13 | type BackoffStrategy func(attempt int) time.Duration 14 | 15 | // Criteria defines when a retry should be attempted. 16 | type Criteria func(tc minds.ThreadContext, attempt int, err error) bool 17 | 18 | // Options defines retry behavior. 19 | type Options struct { 20 | Attempts int 21 | Backoff BackoffStrategy 22 | ShouldRetry Criteria 23 | PropagateTimeout bool 24 | } 25 | 26 | // NewDefaultOptions returns default retry options. 27 | func NewDefaultOptions() *Options { 28 | return &Options{ 29 | Attempts: 3, 30 | Backoff: DefaultBackoff(0), 31 | ShouldRetry: DefaultCriteria, 32 | PropagateTimeout: true, 33 | } 34 | } 35 | 36 | // DefaultBackoff provides a simple constant delay. 37 | func DefaultBackoff(delay time.Duration) BackoffStrategy { 38 | return func(_ int) time.Duration { 39 | return delay 40 | } 41 | } 42 | 43 | // DefaultCriteria retries on all errors. 44 | func DefaultCriteria(tc minds.ThreadContext, attempt int, err error) bool { 45 | return err != nil 46 | } 47 | 48 | // WithAttempts configures max retry attempts. 49 | func WithAttempts(attempts int) Option { 50 | return func(config *Options) { 51 | config.Attempts = attempts 52 | } 53 | } 54 | 55 | // WithBackoff sets a custom backoff strategy. 56 | func WithBackoff(strategy BackoffStrategy) Option { 57 | return func(config *Options) { 58 | config.Backoff = strategy 59 | } 60 | } 61 | 62 | // WithRetryCriteria sets a custom retry criteria. 63 | func WithRetryCriteria(criteria Criteria) Option { 64 | return func(config *Options) { 65 | config.ShouldRetry = criteria 66 | } 67 | } 68 | 69 | // WithoutTimeoutPropagation disables context timeout propagation. 70 | func WithoutTimeoutPropagation() Option { 71 | return func(config *Options) { 72 | config.PropagateTimeout = false 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /providers/gemini/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chriscow/minds/providers/gemini 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.4 6 | 7 | // replace github.com/chriscow/minds => ../../ 8 | 9 | require ( 10 | cloud.google.com/go/ai v0.10.0 11 | github.com/google/generative-ai-go v0.19.0 12 | github.com/matryer/is v1.4.1 13 | google.golang.org/api v0.217.0 14 | ) 15 | 16 | require ( 17 | cloud.google.com/go v0.118.0 // indirect 18 | cloud.google.com/go/auth v0.14.0 // indirect 19 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 20 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 21 | cloud.google.com/go/longrunning v0.6.4 // indirect 22 | github.com/chriscow/minds v0.0.5 // indirect 23 | github.com/felixge/httpsnoop v1.0.4 // indirect 24 | github.com/go-logr/logr v1.4.2 // indirect 25 | github.com/go-logr/stdr v1.2.2 // indirect 26 | github.com/google/s2a-go v0.1.9 // indirect 27 | github.com/google/uuid v1.6.0 // indirect 28 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 29 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 30 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 31 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 32 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 33 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect 34 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 35 | go.opentelemetry.io/otel v1.34.0 // indirect 36 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 37 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 38 | golang.org/x/crypto v0.32.0 // indirect 39 | golang.org/x/net v0.34.0 // indirect 40 | golang.org/x/oauth2 v0.25.0 // indirect 41 | golang.org/x/sync v0.10.0 // indirect 42 | golang.org/x/sys v0.29.0 // indirect 43 | golang.org/x/text v0.21.0 // indirect 44 | golang.org/x/time v0.9.0 // indirect 45 | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect 46 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect 47 | google.golang.org/grpc v1.69.4 // indirect 48 | google.golang.org/protobuf v1.36.3 // indirect 49 | gopkg.in/yaml.v2 v2.4.0 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /providers/openai/summarize_test.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "testing" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/handlers" 11 | "github.com/matryer/is" 12 | ) 13 | 14 | // TestSummarize tests the Summerize handler using the OpenAI provider. This 15 | // test lives in the openai module to avoid dragging in dependencies into the 16 | // minds module. 17 | func TestSummarize(t *testing.T) { 18 | is := is.New(t) 19 | 20 | fmt.Println(os.Getenv("OPENAI_API_KEY")) 21 | apiToken := os.Getenv("OPENAI_API_KEY") 22 | if apiToken == "" { 23 | t.Skip("Skipping integration test against OpenAI API. Set OPENAI_API_KEY environment variable to enable it.") 24 | } 25 | 26 | llm, err := NewProvider(WithAPIKey(apiToken)) 27 | is.NoErr(err) 28 | 29 | systemMsg := "you are a helpful summerization assistant" 30 | 31 | summarizer := handlers.Summarize(llm, systemMsg) 32 | tc := minds.NewThreadContext(context.Background()).WithMessages(minds.Messages{ 33 | {Role: minds.RoleSystem, Content: systemMsg}, 34 | {Role: minds.RoleUser, Content: "What is the meaning of life?"}, 35 | {Role: minds.RoleAssistant, Content: ` 36 | The meaning of life is a deeply personal and philosophical question that has been explored for centuries by thinkers, religions, and individuals. Some common perspectives include: 37 | 38 | - **Philosophical**: To seek knowledge, understanding, or personal fulfillment. 39 | - **Religious/Spiritual**: To serve a higher purpose, connect with the divine, or achieve spiritual enlightenment. 40 | - **Biological**: To survive and propagate the species. 41 | - **Existential**: To create meaning through personal choices and actions in an otherwise neutral universe. 42 | - **Hedonistic**: To pursue happiness and minimize suffering. 43 | 44 | Ultimately, the meaning of life is what you define it to be, based on your beliefs, values, and experiences. What feels meaningful to you?`, 45 | }, 46 | }...) 47 | 48 | result, err := summarizer.HandleThread(tc, nil) 49 | is.NoErr(err) 50 | msgOut := result.Messages() 51 | 52 | is.True(len(msgOut) == 1) 53 | is.True(msgOut[0].Role == minds.RoleSystem) 54 | is.True(len(msgOut[0].Content) > len(systemMsg)) 55 | } 56 | -------------------------------------------------------------------------------- /providers/openai/go.sum: -------------------------------------------------------------------------------- 1 | github.com/chriscow/minds v0.0.5 h1:OdFYZzeVUDxMgalRO1whYP1yfHRzM+Z0puLM2duTeq4= 2 | github.com/chriscow/minds v0.0.5/go.mod h1:iznO9umfPFv30TzYbhmhSk5h1LiVPsNw//LX46AUH2k= 3 | github.com/dlclark/regexp2 v1.9.0 h1:pTK/l/3qYIKaRXuHnEnIf7Y5NxfRPfpb7dis6/gdlVI= 4 | github.com/dlclark/regexp2 v1.9.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 5 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 6 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 7 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 8 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 9 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 10 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 11 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 12 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 13 | github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= 14 | github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= 15 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 16 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 17 | github.com/sashabaranov/go-openai v1.36.0 h1:fcSrn8uGuorzPWCBp8L0aCR95Zjb/Dd+ZSML0YZy9EI= 18 | github.com/sashabaranov/go-openai v1.36.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 19 | github.com/tiktoken-go/tokenizer v0.2.1 h1:/VBr0BUWaSO1yMsnJliVVyCmEMzHDzTJNYxWxR0jWQA= 20 | github.com/tiktoken-go/tokenizer v0.2.1/go.mod h1:7SZW3pZUKWLJRilTvWCa86TOVIiiJhYj3FQ5V3alWcg= 21 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 22 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 25 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 26 | -------------------------------------------------------------------------------- /_examples/handlers-switch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/chriscow/minds" 9 | "github.com/chriscow/minds/handlers" 10 | "github.com/chriscow/minds/providers/openai" 11 | "github.com/chriscow/minds/tools/calculator" 12 | "github.com/chriscow/minds/tools/extensions" 13 | ) 14 | 15 | func main() { 16 | llm, _ := openai.NewProvider() 17 | 18 | // Create specialized handlers for different tasks 19 | calc, _ := calculator.NewCalculator(calculator.Lua) 20 | toolCaller, err := openai.NewProvider(openai.WithTool(calc)) 21 | if err != nil { 22 | log.Fatalf("Error creating tool caller: %v", err) 23 | } 24 | 25 | questionHandler := llm // let the llm answer questions 26 | summaryHandler := handlers.NewSummarizer(llm, "") 27 | defaultHandler := llm 28 | 29 | // Define conditions and their handlers 30 | intentSwitch := handlers.NewSwitch("intent-router", 31 | defaultHandler, // fallback handler 32 | handlers.SwitchCase{ 33 | // Use LLM to check if message is a math question 34 | Condition: handlers.LLMCondition{ 35 | Generator: llm, 36 | Prompt: "Does this message contain a mathematical calculation?", 37 | }, 38 | Handler: toolCaller, 39 | }, 40 | handlers.SwitchCase{ 41 | // Check metadata for specific routing 42 | Condition: handlers.MetadataEquals{ 43 | Key: "type", 44 | Value: "question", 45 | }, 46 | Handler: questionHandler, 47 | }, 48 | handlers.SwitchCase{ 49 | // Use Lua for complex condition 50 | Condition: extensions.LuaCondition{ 51 | Script: ` 52 | -- Check if message is long and needs summarization 53 | return string.len(last_message) > 500 54 | `, 55 | }, 56 | Handler: summaryHandler, 57 | }, 58 | ) 59 | 60 | // Initial thread with metadata 61 | thread := minds.NewThreadContext(context.Background()). 62 | WithMessages(minds.Message{Role: minds.RoleUser, Content: "What is 7 * 12 + 5?"}). 63 | WithMetadata(map[string]any{ 64 | "type": "calculation", 65 | }) 66 | 67 | // Process the thread 68 | result, err := intentSwitch.HandleThread(thread, nil) 69 | if err != nil { 70 | log.Fatalf("Error processing thread: %v", err) 71 | } 72 | 73 | fmt.Println("Response:", result.Messages().Last().Content) 74 | } 75 | -------------------------------------------------------------------------------- /_examples/handlers-sequence/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/handlers" 11 | "github.com/chriscow/minds/providers/openai" 12 | ) 13 | 14 | // This example demonstrates the `Sequential` handler. The `Sequential` handler 15 | // executes each handler in order. Here we compose multiple handlers into a 16 | // single pipeline. 17 | func main() { 18 | if os.Getenv("GEMINI_API_KEY") == "" { 19 | log.Fatalf("GEMINI_API_KEY is not set") 20 | } 21 | 22 | ctx := context.Background() 23 | 24 | managerPrompt := `You are a pointy-haired boss from Dilbert. You speak in management buzzwords 25 | and crazy ideas. Keep responses short and focused on maximizing synergy and 26 | disrupting paradigms. You have no technical understanding but pretend you do. 27 | Never break character. Prefix your response with [Boss:]` 28 | 29 | engineerPrompt := `You are Dilbert, a cynical software engineer. You respond to management 30 | with dry wit and technical accuracy while pointing out logical flaws. Keep responses 31 | short and sardonic. Never break character. Prefix your response with [Dilbert:]` 32 | 33 | manager, err := openai.NewProvider(openai.WithSystemPrompt(managerPrompt)) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | engineer, err := openai.NewProvider(openai.WithSystemPrompt(engineerPrompt)) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | tc := minds.NewThreadContext(ctx).WithMessages(minds.Message{ 44 | Role: minds.RoleUser, Content: "We need to leverage blockchain AI to disrupt our coffee machine's paradigm", 45 | }) 46 | 47 | comic := handlers.NewSequence("comic", manager, engineer, manager, engineer) 48 | result, err := comic.HandleThread(tc, comic) 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | for _, message := range result.Messages() { 54 | fmt.Printf("%s\n", message.Content) 55 | } 56 | 57 | // Output should look something like: 58 | // Boss: We need to synergize our coffee consumption metrics with blockchain-enabled AI... 59 | // Dilbert: So... you want to put a computer chip in the coffee maker? 60 | // Boss: Exactly! And we'll call it CoffeeChain 3.0 - it's like Web3 but for caffeine... 61 | // Dilbert: *sigh* I'll just order a new Mr. Coffee from Amazon. 62 | } 63 | -------------------------------------------------------------------------------- /handlers/sequence.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // sequence executes a series of handlers in order, stopping if any handler returns 10 | // an error. It supports middleware through the MiddlewareHandler interface. 11 | type sequence struct { 12 | name string 13 | handlers []minds.ThreadHandler 14 | middleware []minds.Middleware 15 | } 16 | 17 | // NewSequence creates a new Sequence handler with the given name and handlers. 18 | // The sequence executes handlers in order, stopping on the first error. 19 | // 20 | // Example: 21 | // 22 | // validate := NewValidationHandler() 23 | // process := NewProcessingHandler() 24 | // 25 | // // Create a sequence with retry middleware 26 | // seq := NewSequence("main", validate, process) 27 | // seq.Use(RetryMiddleware()) 28 | // 29 | // // Or create a variation with different middleware 30 | // timeoutSeq := seq.With(TimeoutMiddleware()) 31 | // 32 | // result, err := seq.HandleThread(tc, nil) 33 | func NewSequence(name string, handlers ...minds.ThreadHandler) *sequence { 34 | return &sequence{ 35 | name: name, 36 | handlers: handlers, 37 | middleware: make([]minds.Middleware, 0), 38 | } 39 | } 40 | 41 | // Use adds middleware that will wrap each child handler 42 | func (s *sequence) Use(middleware ...minds.Middleware) { 43 | s.middleware = append(s.middleware, middleware...) 44 | } 45 | 46 | // HandleThread processes each handler in sequence, wrapping each with middleware 47 | func (s *sequence) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 48 | current := tc 49 | 50 | // Execute each handler in sequence 51 | for _, h := range s.handlers { 52 | // Start with the base handler 53 | wrappedHandler := h 54 | 55 | // Apply middleware in reverse order for proper nesting 56 | for i := len(s.middleware) - 1; i >= 0; i-- { 57 | wrappedHandler = s.middleware[i].Wrap(wrappedHandler) 58 | } 59 | 60 | // Execute the wrapped handler 61 | var err error 62 | current, err = wrappedHandler.HandleThread(current, nil) 63 | if err != nil { 64 | return current, fmt.Errorf("%s: handler error: %w", s.name, err) 65 | } 66 | } 67 | 68 | // Execute next handler if provided 69 | if next != nil { 70 | return next.HandleThread(current, nil) 71 | } 72 | 73 | return current, nil 74 | } 75 | 76 | func (s *sequence) String() string { 77 | return fmt.Sprintf("Sequence(%s)", s.name) 78 | } 79 | -------------------------------------------------------------------------------- /providers/openai/options.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/chriscow/minds" 7 | retryablehttp "github.com/hashicorp/go-retryablehttp" 8 | ) 9 | 10 | type Options struct { 11 | name string 12 | apiKey string 13 | baseURL string 14 | modelName string 15 | temperature *float32 16 | maxOutputTokens *int 17 | schema *minds.ResponseSchema 18 | tools []minds.Tool 19 | registry minds.ToolRegistry 20 | systemPrompt *string 21 | httpClient *http.Client 22 | } 23 | 24 | type Option func(*Options) 25 | 26 | func WithName(name string) Option { 27 | return func(o *Options) { 28 | o.name = name 29 | } 30 | } 31 | 32 | func WithAPIKey(key string) Option { 33 | return func(o *Options) { 34 | o.apiKey = key 35 | } 36 | } 37 | 38 | func WithBaseURL(url string) Option { 39 | return func(o *Options) { 40 | o.baseURL = url 41 | } 42 | } 43 | 44 | func WithModel(model string) Option { 45 | return func(o *Options) { 46 | o.modelName = model 47 | } 48 | } 49 | 50 | func WithTemperature(temperature float32) Option { 51 | return func(o *Options) { 52 | o.temperature = &temperature 53 | } 54 | } 55 | 56 | func WithMaxOutputTokens(tokens int) Option { 57 | return func(o *Options) { 58 | o.maxOutputTokens = &tokens 59 | } 60 | } 61 | 62 | func WithResponseSchema(schema minds.ResponseSchema) Option { 63 | return func(o *Options) { 64 | o.schema = &schema 65 | } 66 | } 67 | 68 | func WithTool(fn minds.Tool) Option { 69 | return func(o *Options) { 70 | if o.tools == nil { 71 | o.tools = make([]minds.Tool, 0) 72 | } 73 | 74 | o.tools = append(o.tools, fn) 75 | } 76 | } 77 | 78 | func WithToolRegistry(registry minds.ToolRegistry) Option { 79 | return func(o *Options) { 80 | if o.registry != nil && len(o.registry.List()) > 0 { 81 | panic("cannot set registry when functions are present in existing registry") 82 | } 83 | 84 | o.registry = registry 85 | } 86 | } 87 | 88 | func WithSystemPrompt(prompt string) Option { 89 | return func(o *Options) { 90 | o.systemPrompt = &prompt 91 | } 92 | } 93 | 94 | func WithClient(client *http.Client) Option { 95 | return func(o *Options) { 96 | o.httpClient = client 97 | } 98 | } 99 | 100 | func WithRetry(max int) Option { 101 | return func(o *Options) { 102 | client := retryablehttp.NewClient() 103 | client.RetryMax = max 104 | 105 | o.httpClient = client.StandardClient() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /providers/openai/handler_test.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/chriscow/minds" 13 | 14 | "github.com/matryer/is" 15 | ) 16 | 17 | func TestHandleMessage(t *testing.T) { 18 | t.Run("returns updated thread", func(t *testing.T) { 19 | is := is.New(t) 20 | 21 | // Create a test server that returns mock responses 22 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Set("Content-Type", "application/json") 24 | json.NewEncoder(w).Encode(newMockTextResponse()) 25 | })) 26 | defer server.Close() 27 | 28 | var provider minds.ContentGenerator 29 | provider, err := NewProvider(WithBaseURL(server.URL)) 30 | is.NoErr(err) // NewProvider should not return an error 31 | 32 | handler, ok := provider.(minds.ThreadHandler) 33 | is.True(ok) // provider should implement the ThreadHandler interface 34 | 35 | thread := minds.NewThreadContext(context.Background()). 36 | WithMessages(minds.Message{ 37 | Role: minds.RoleUser, Content: "Hi", 38 | }) 39 | 40 | result, err := handler.HandleThread(thread, nil) 41 | is.NoErr(err) // HandleMessage should not return an error 42 | messages := result.Messages() 43 | is.Equal(len(messages), 2) 44 | is.Equal(messages[1].Role, minds.RoleAssistant) 45 | is.Equal(messages[1].Content, "Hello, world!") 46 | }) 47 | 48 | t.Run("returns error on failure", func(t *testing.T) { 49 | is := is.New(t) 50 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) 51 | cancel() 52 | 53 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 54 | w.Header().Set("Content-Type", "application/json") 55 | json.NewEncoder(w).Encode(newMockTextResponse()) 56 | })) 57 | defer server.Close() 58 | 59 | var provider minds.ContentGenerator 60 | provider, err := NewProvider(WithBaseURL(server.URL)) 61 | is.NoErr(err) // NewProvider should not return an error 62 | 63 | handler, ok := provider.(minds.ThreadHandler) 64 | is.True(ok) // provider should implement the ThreadHandler interface 65 | 66 | thread := minds.NewThreadContext(ctx). 67 | WithMessages(minds.Message{ 68 | Role: minds.RoleUser, Content: "Hi", 69 | }) 70 | 71 | _, err = handler.HandleThread(thread, nil) 72 | is.True(err != nil) // HandleMessage should return an error 73 | is.True(strings.Contains(err.Error(), context.DeadlineExceeded.Error())) 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /providers/gemini/options.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/chriscow/minds" 7 | retryablehttp "github.com/hashicorp/go-retryablehttp" 8 | 9 | "github.com/google/generative-ai-go/genai" 10 | ) 11 | 12 | type Options struct { 13 | name string 14 | apiKey string 15 | baseURL string 16 | modelName string 17 | temperature *float32 18 | maxOutputTokens *int32 19 | schema *genai.Schema 20 | tools []minds.Tool 21 | registry minds.ToolRegistry 22 | systemPrompt *string 23 | httpClient *http.Client 24 | } 25 | 26 | type Option func(*Options) 27 | 28 | func WithName(name string) Option { 29 | return func(o *Options) { 30 | o.name = name 31 | } 32 | } 33 | 34 | func WithAPIKey(key string) Option { 35 | return func(o *Options) { 36 | o.apiKey = key 37 | } 38 | } 39 | 40 | func WithBaseURL(url string) Option { 41 | return func(o *Options) { 42 | o.baseURL = url 43 | } 44 | } 45 | 46 | func WithModel(model string) Option { 47 | return func(o *Options) { 48 | o.modelName = model 49 | } 50 | } 51 | 52 | func WithTemperature(temperature float32) Option { 53 | return func(o *Options) { 54 | o.temperature = &temperature 55 | } 56 | } 57 | 58 | func WithMaxOutputTokens(tokens int) Option { 59 | return func(o *Options) { 60 | maxTokens := int32(tokens) 61 | o.maxOutputTokens = &maxTokens 62 | } 63 | } 64 | 65 | func WithResponseSchema(schema *genai.Schema) Option { 66 | return func(o *Options) { 67 | o.schema = schema 68 | } 69 | } 70 | 71 | func WithSystemPrompt(prompt string) Option { 72 | return func(o *Options) { 73 | o.systemPrompt = &prompt 74 | } 75 | } 76 | 77 | func WithTool(fn minds.Tool) Option { 78 | return func(o *Options) { 79 | if o.tools == nil { 80 | o.tools = make([]minds.Tool, 0) 81 | } 82 | o.tools = append(o.tools, fn) 83 | } 84 | } 85 | 86 | func WithToolRegistry(registry minds.ToolRegistry) Option { 87 | return func(o *Options) { 88 | if o.registry != nil && len(o.registry.List()) > 0 { 89 | panic("cannot set registry when functions are present in existing registry") 90 | } 91 | o.registry = registry 92 | } 93 | } 94 | 95 | func WithClient(client *http.Client) Option { 96 | return func(o *Options) { 97 | o.httpClient = client 98 | } 99 | } 100 | 101 | func WithRetry(max int) Option { 102 | return func(o *Options) { 103 | client := retryablehttp.NewClient() 104 | client.RetryMax = max 105 | 106 | o.httpClient = client.StandardClient() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /providers/gemini/handler.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "github.com/chriscow/minds" 5 | ) 6 | 7 | /* 8 | 9 | // HandleMessage implements the ThreadHandler interface for the OpenAI provider. 10 | func (p *Provider) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 11 | 12 | messages := minds.Messages{} 13 | if p.options.systemPrompt != nil { 14 | messages = append(messages, minds.Message{ 15 | Role: minds.RoleSystem, 16 | Content: *p.options.systemPrompt, 17 | }) 18 | } 19 | 20 | messages = append(messages, tc.Messages()...) 21 | 22 | req := minds.Request{ 23 | Messages: messages, 24 | } 25 | 26 | for i, m := range req.Messages { 27 | switch m.Role { 28 | case minds.RoleModel: 29 | req.Messages[i].Role = minds.RoleAssistant 30 | } 31 | } 32 | 33 | resp, err := p.GenerateContent(tc.Context(), req) 34 | if err != nil { 35 | return tc, fmt.Errorf("failed to generate content: %w", err) 36 | } 37 | // fmt.Printf("[%s] %s\n", p.options.name, resp.String()) 38 | 39 | msg := minds.Message{ 40 | Role: minds.RoleAssistant, 41 | Name: p.options.name, 42 | Content: resp.String(), 43 | } 44 | 45 | tc.AppendMessages(msg) 46 | 47 | if next != nil { 48 | return next.HandleThread(tc, nil) 49 | } 50 | 51 | return tc, nil 52 | } 53 | 54 | func (p *Provider) String() string { 55 | return fmt.Sprintf("OpenAI Provider: %s", p.options.name) 56 | } 57 | 58 | */ 59 | 60 | // HandleMessage implements the ThreadHandler interface for the Gemini provider. 61 | func (p *Provider) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 62 | 63 | messages := minds.Messages{} 64 | if p.options.systemPrompt != nil { 65 | messages = append(messages, minds.Message{ 66 | Role: minds.RoleSystem, 67 | Content: *p.options.systemPrompt, 68 | }) 69 | } 70 | 71 | messages = append(messages, tc.Messages()...) 72 | 73 | req := minds.Request{ 74 | Messages: messages, 75 | } 76 | 77 | for i, m := range req.Messages { 78 | switch m.Role { 79 | case minds.RoleModel: 80 | req.Messages[i].Role = minds.RoleAssistant 81 | } 82 | } 83 | 84 | resp, err := p.GenerateContent(tc.Context(), req) 85 | if err != nil { 86 | return tc, err 87 | } 88 | 89 | msg := minds.Message{ 90 | Role: minds.RoleAssistant, 91 | Name: p.options.name, 92 | Content: resp.String(), 93 | } 94 | 95 | tc.AppendMessages(msg) 96 | 97 | if next != nil { 98 | return next.HandleThread(tc, nil) 99 | } 100 | 101 | return tc, nil 102 | } 103 | -------------------------------------------------------------------------------- /providers/gemini/response.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | 8 | "github.com/google/generative-ai-go/genai" 9 | ) 10 | 11 | // Response represents a unified response type that can handle both 12 | // text responses and function calls from the Gemini API 13 | type Response struct { 14 | raw *genai.GenerateContentResponse 15 | calls []minds.ToolCall 16 | } 17 | 18 | // NewResponse creates a new Response from a Gemini API response 19 | func NewResponse(resp *genai.GenerateContentResponse, calls []minds.ToolCall) (*Response, error) { 20 | if len(resp.Candidates) == 0 { 21 | return nil, fmt.Errorf("no candidates in Gemini response") 22 | } 23 | 24 | if len(resp.Candidates) > 1 { 25 | return nil, fmt.Errorf("more than one candidate in Gemini response") 26 | } 27 | 28 | candidate := resp.Candidates[0] 29 | if candidate.Content == nil { 30 | return nil, fmt.Errorf("candidate content is nil") 31 | } 32 | 33 | if len(candidate.Content.Parts) == 0 { 34 | return nil, fmt.Errorf("candidate content has no parts") 35 | } 36 | 37 | if calls == nil { 38 | calls = make([]minds.ToolCall, 0) 39 | } 40 | 41 | return &Response{raw: resp, calls: calls}, nil 42 | } 43 | 44 | // String returns the text content if this is a text response. 45 | // For function calls, it returns a formatted representation of the call. 46 | func (r *Response) String() string { 47 | if len(r.raw.Candidates) == 0 { 48 | return "" 49 | } 50 | 51 | if len(r.raw.Candidates[0].Content.Parts) == 0 { 52 | return "" 53 | } 54 | 55 | text, ok := r.raw.Candidates[0].Content.Parts[0].(genai.Text) 56 | if !ok { 57 | return "" 58 | } 59 | 60 | return string(text) 61 | } 62 | 63 | // ToolCalls returns the function call details if this is a function call response. 64 | // Returns nil and false if this isn't a function call. 65 | func (r *Response) ToolCalls() []minds.ToolCall { 66 | return r.calls 67 | } 68 | 69 | // Raw returns the underlying Gemini response 70 | func (r *Response) Raw() *genai.GenerateContentResponse { 71 | return r.raw 72 | } 73 | 74 | func (r *Response) Messages() (minds.Messages, error) { 75 | messages := minds.Messages{} 76 | 77 | for _, call := range r.calls { 78 | messages = append(messages, minds.Message{ 79 | Role: minds.RoleTool, 80 | Content: string(call.Function.Result), 81 | ToolCallID: call.ID, 82 | }) 83 | } 84 | 85 | for _, part := range r.raw.Candidates[0].Content.Parts { 86 | text, ok := part.(genai.Text) 87 | if ok { 88 | messages = append(messages, minds.Message{ 89 | Role: minds.Role(r.raw.Candidates[0].Content.Role), 90 | Content: string(text), 91 | }) 92 | } 93 | } 94 | 95 | return messages, nil 96 | } 97 | -------------------------------------------------------------------------------- /tools/extensions/lua_condition.go: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | lua "github.com/yuin/gopher-lua" 8 | ) 9 | 10 | // LuaCondition implements SwitchCondition by evaluating a Lua script. The script is 11 | // executed in a fresh Lua state for each evaluation and must return a boolean value. 12 | // 13 | // The script has access to the following global variables: 14 | // - metadata: A string representation of the thread context's metadata map 15 | // - last_message: The content of the last message in the thread (if available) 16 | // 17 | // Example usage: 18 | // 19 | // condition := LuaCondition{ 20 | // Script: ` 21 | // -- Check if metadata contains "type=question" 22 | // return string.find(metadata, "type=question") ~= nil 23 | // `, 24 | // } 25 | // result, err := condition.Evaluate(threadContext) 26 | type LuaCondition struct { 27 | // Script contains the Lua code to be executed. 28 | // The script must return a boolean value as its final result. 29 | Script string 30 | } 31 | 32 | // Evaluate executes the Lua script and returns its boolean result. It creates a new 33 | // Lua state, populates it with thread context data, executes the script, and 34 | // interprets the result. 35 | // 36 | // The function provides the following global variables to the Lua environment: 37 | // - metadata: String representation of tc.Metadata() 38 | // - last_message: Content of the last message in tc.Messages() (if any) 39 | // 40 | // Returns an error if: 41 | // - The script execution fails 42 | // - The script returns a non-boolean value 43 | func (l LuaCondition) Evaluate(tc minds.ThreadContext) (bool, error) { 44 | // Create a new Lua state for this evaluation 45 | L := lua.NewState() 46 | defer L.Close() 47 | 48 | // Expose thread context to Lua environment 49 | // Convert metadata to string since Lua has limited type support 50 | L.SetGlobal("metadata", lua.LString(fmt.Sprintf("%v", tc.Metadata()))) 51 | 52 | // Make the last message content available if the thread has messages 53 | if len(tc.Messages()) > 0 { 54 | L.SetGlobal("last_message", lua.LString(tc.Messages()[len(tc.Messages())-1].Content)) 55 | } 56 | 57 | // Execute the Lua script 58 | if err := L.DoString(l.Script); err != nil { 59 | return false, fmt.Errorf("error executing Lua script: %w", err) 60 | } 61 | 62 | // Get the result from the top of the Lua stack 63 | result := L.Get(-1) // Get last value 64 | L.Pop(1) // Remove it from stack 65 | 66 | // Ensure the result is a boolean and convert it to Go bool 67 | switch v := result.(type) { 68 | case lua.LBool: 69 | return bool(v), nil 70 | default: 71 | return false, fmt.Errorf("lua script must return a boolean") 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | type ThreadHandler interface { 4 | HandleThread(thread ThreadContext, next ThreadHandler) (ThreadContext, error) 5 | } 6 | 7 | type ThreadHandlerFunc func(thread ThreadContext, next ThreadHandler) (ThreadContext, error) 8 | 9 | func (f ThreadHandlerFunc) HandleThread(thread ThreadContext, next ThreadHandler) (ThreadContext, error) { 10 | return f(thread, next) 11 | } 12 | 13 | func (f ThreadHandlerFunc) Wrap(next ThreadHandler) ThreadHandler { 14 | return ThreadHandlerFunc(func(tc ThreadContext, final ThreadHandler) (ThreadContext, error) { 15 | return f(tc, next) 16 | }) 17 | } 18 | 19 | type NoopThreadHandler struct{} 20 | 21 | func (h NoopThreadHandler) HandleThread(tc ThreadContext, next ThreadHandler) (ThreadContext, error) { 22 | return tc, nil 23 | } 24 | 25 | // type MiddlewareHandler interface { 26 | // ThreadHandler 27 | 28 | // // Use adds middleware to the handler 29 | // Use(middleware ...Middleware) 30 | 31 | // // WithMiddleware returns a new handler with the provided middleware applied 32 | // With(middleware ...Middleware) ThreadHandler 33 | // } 34 | 35 | // HandlerExecutor defines a strategy for executing child handlers. It receives 36 | // the current ThreadContext, a slice of handlers to execute, and an optional next 37 | // handler to call after all handlers have been processed. The executor is responsible 38 | // for defining how and when each handler is executed. 39 | // type HandlerExecutor func(tc ThreadContext, handlers []ThreadHandler, next ThreadHandler) (ThreadContext, error) 40 | 41 | // Middleware represents a function that can wrap a ThreadHandler 42 | type Middleware interface { 43 | Wrap(next ThreadHandler) ThreadHandler 44 | } 45 | 46 | // MiddlewareFunc is a function that implements the Middleware interface 47 | type MiddlewareFunc func(next ThreadHandler) ThreadHandler 48 | 49 | func (f MiddlewareFunc) Wrap(next ThreadHandler) ThreadHandler { 50 | return f(next) 51 | } 52 | 53 | // NewMiddleware creates a new middleware that runs a function before passing control 54 | // to the next handler. The provided function can modify the ThreadContext and 55 | // return an error to halt processing. 56 | // func NewMiddleware(name string, fn func(tc ThreadContext) error) Middleware { 57 | // return MiddlewareFunc(func(next ThreadHandler) ThreadHandler { 58 | // return ThreadHandlerFunc(func(tc ThreadContext, _ ThreadHandler) (ThreadContext, error) { 59 | // if err := fn(tc); err != nil { 60 | // return tc, fmt.Errorf("%s: %w", name, err) 61 | // } 62 | // if next == nil { 63 | // next = NoopThreadHandler{} 64 | // } 65 | // return next.HandleThread(tc, nil) 66 | // }) 67 | // }) 68 | // } 69 | 70 | // SupportsMiddleware checks if the given handler supports middleware operations 71 | // func SupportsMiddleware(h ThreadHandler) (MiddlewareHandler, bool) { 72 | // mh, ok := h.(MiddlewareHandler) 73 | // return mh, ok 74 | // } 75 | -------------------------------------------------------------------------------- /tools/calculator/calculator_lua_test.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/matryer/is" 8 | ) 9 | 10 | func TestCalculatorLua(t *testing.T) { 11 | is := is.New(t) 12 | 13 | t.Run("basic lua addition", func(t *testing.T) { 14 | calc, err := NewCalculator(Lua) 15 | is.NoErr(err) 16 | result, err := calc.Call(context.Background(), []byte(`{"input":"1 + 2"}`)) 17 | is.NoErr(err) 18 | is.Equal(string(result), "3") 19 | }) 20 | 21 | t.Run("basic lua subtraction", func(t *testing.T) { 22 | calc, err := NewCalculator(Lua) 23 | is.NoErr(err) 24 | result, err := calc.Call(context.Background(), []byte(`{"input":"2 - 1"}`)) 25 | is.NoErr(err) 26 | is.Equal(string(result), "1") 27 | }) 28 | 29 | t.Run("basic lua multiplication", func(t *testing.T) { 30 | calc, err := NewCalculator(Lua) 31 | is.NoErr(err) 32 | result, err := calc.Call(context.Background(), []byte(`{"input":"2 * 3"}`)) 33 | is.NoErr(err) 34 | is.Equal(string(result), "6") 35 | }) 36 | 37 | t.Run("basic lua division", func(t *testing.T) { 38 | calc, err := NewCalculator(Lua) 39 | is.NoErr(err) 40 | result, err := calc.Call(context.Background(), []byte(`{"input":"6 / 2"}`)) 41 | is.NoErr(err) 42 | is.Equal(string(result), "3") 43 | }) 44 | 45 | t.Run("basic lua modulo", func(t *testing.T) { 46 | calc, err := NewCalculator(Lua) 47 | is.NoErr(err) 48 | result, err := calc.Call(context.Background(), []byte(`{"input":"7 % 3"}`)) // In Lua, modulo is also % 49 | is.NoErr(err) 50 | is.Equal(string(result), "1") 51 | }) 52 | 53 | t.Run("basic lua exponentiation", func(t *testing.T) { 54 | calc, err := NewCalculator(Lua) 55 | is.NoErr(err) 56 | // In Lua, we can use either math.pow() or the ^ operator 57 | script := `{"input":"math.sqrt(16) + math.pow(2, 3)"}` 58 | result, err := calc.Call(context.Background(), []byte(script)) 59 | is.NoErr(err) 60 | is.Equal(string(result), "12") 61 | }) 62 | 63 | // Additional Lua-specific tests 64 | t.Run("lua power operator", func(t *testing.T) { 65 | calc, err := NewCalculator(Lua) 66 | is.NoErr(err) 67 | result, err := calc.Call(context.Background(), []byte(`{"input":"2^3"}`)) // Lua's built-in power operator 68 | is.NoErr(err) 69 | is.Equal(string(result), "8") 70 | }) 71 | 72 | t.Run("lua number formatting", func(t *testing.T) { 73 | calc, err := NewCalculator(Lua) 74 | is.NoErr(err) 75 | result, err := calc.Call(context.Background(), []byte(`{"input":"22/7"}`)) // Will show decimal places 76 | is.NoErr(err) 77 | is.Equal(string(result)[:4], "3.14") // Check first 4 characters for approximate pi 78 | }) 79 | 80 | t.Run("lua math constants", func(t *testing.T) { 81 | calc, err := NewCalculator(Lua) 82 | is.NoErr(err) 83 | result, err := calc.Call(context.Background(), []byte(`{"input":"math.pi"}`)) 84 | is.NoErr(err) 85 | is.Equal(string(result)[:4], "3.14") // Check first 4 characters for pi 86 | }) 87 | } 88 | -------------------------------------------------------------------------------- /prompt.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "html/template" 7 | "os" 8 | "path" 9 | "strings" 10 | 11 | "github.com/chriscow/minds/internal/utils" 12 | "gopkg.in/yaml.v2" 13 | ) 14 | 15 | type PromptHeader struct { 16 | Name string 17 | Version string `yaml:"version,ignoreempty"` 18 | Format string `yaml:"format,ignoreempty"` 19 | SHA256 string `yaml:"sha256,ignoreempty"` 20 | Extra map[string]any `yaml:",inline"` 21 | } 22 | 23 | type Prompt struct { 24 | Header PromptHeader 25 | Template *template.Template 26 | } 27 | 28 | func (p Prompt) Execute(data any) (string, error) { 29 | var result strings.Builder 30 | if err := p.Template.Execute(&result, data); err != nil { 31 | return "", err 32 | } 33 | return result.String(), nil 34 | } 35 | 36 | func CreateTemplate(fs embed.FS, filepath string) (Prompt, error) { 37 | var prompt Prompt 38 | content, err := fs.ReadFile(filepath) 39 | if err != nil { 40 | return prompt, err 41 | } 42 | 43 | header, body, err := extractYAMLHeader(string(content)) 44 | if err != nil { 45 | return prompt, err 46 | } 47 | sha, err := utils.SHA256Hash([]byte(body)) 48 | if err != nil { 49 | return prompt, err 50 | } 51 | if header.SHA256 != "" && header.SHA256 != sha { 52 | return prompt, fmt.Errorf("SHA256 mismatch. Bump the version and update the SHA256") 53 | } else if header.SHA256 == "" { 54 | header.SHA256 = sha 55 | if err := SavePromptTemplate(filepath, header, body); err != nil { 56 | return prompt, err 57 | } 58 | } 59 | prompt.Header = header 60 | 61 | tmpl, err := template.New(header.Name).Parse(body) 62 | if err != nil { 63 | return prompt, err 64 | } 65 | prompt.Template = tmpl 66 | 67 | return prompt, nil 68 | } 69 | 70 | func SavePromptTemplate(filepath string, header PromptHeader, body string) error { 71 | file, err := os.Create(path.Join("assets", filepath)) 72 | if err != nil { 73 | return err 74 | } 75 | defer file.Close() 76 | 77 | if _, err := file.WriteString("---\n"); err != nil { 78 | return err 79 | } 80 | meta, err := yaml.Marshal(header) 81 | if err != nil { 82 | return err 83 | } 84 | if _, err := file.Write(meta); err != nil { 85 | return err 86 | } 87 | if _, err := file.WriteString("\n---\n"); err != nil { 88 | return err 89 | } 90 | if _, err := file.WriteString(body); err != nil { 91 | return err 92 | } 93 | return nil 94 | } 95 | 96 | func extractYAMLHeader(templateStr string) (PromptHeader, string, error) { 97 | const delimiter = "---" 98 | var header PromptHeader 99 | parts := strings.SplitN(templateStr, delimiter, 3) 100 | if len(parts) != 3 { 101 | return header, "", fmt.Errorf("invalid template format") 102 | } 103 | 104 | meta := strings.TrimSpace(parts[1]) 105 | body := strings.TrimSpace(parts[2]) 106 | 107 | if err := yaml.Unmarshal([]byte(meta), &header); err != nil { 108 | return header, "", err 109 | } 110 | 111 | return header, body, nil 112 | } 113 | -------------------------------------------------------------------------------- /_examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/chriscow/minds-examples 2 | 3 | go 1.22.7 4 | 5 | toolchain go1.23.4 6 | 7 | replace github.com/chriscow/minds => ../ 8 | 9 | replace github.com/chriscow/minds/providers/openai => ../providers/openai 10 | 11 | replace github.com/chriscow/minds/providers/gemini => ../providers/gemini 12 | 13 | replace github.com/chriscow/minds/providers/deepseek => ../providers/deepseek 14 | 15 | replace github.com/chriscow/minds/tools => ../tools 16 | 17 | require ( 18 | github.com/chriscow/minds v0.0.5 19 | github.com/chriscow/minds/providers/gemini v0.0.0-00010101000000-000000000000 20 | github.com/chriscow/minds/providers/openai v0.0.5 21 | github.com/chriscow/minds/tools v0.0.0-00010101000000-000000000000 22 | github.com/fatih/color v1.18.0 23 | golang.org/x/time v0.9.0 24 | ) 25 | 26 | require ( 27 | cloud.google.com/go v0.118.0 // indirect 28 | cloud.google.com/go/ai v0.10.0 // indirect 29 | cloud.google.com/go/auth v0.14.0 // indirect 30 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 31 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 32 | cloud.google.com/go/longrunning v0.6.4 // indirect 33 | github.com/dlclark/regexp2 v1.11.4 // indirect 34 | github.com/felixge/httpsnoop v1.0.4 // indirect 35 | github.com/go-logr/logr v1.4.2 // indirect 36 | github.com/go-logr/stdr v1.2.2 // indirect 37 | github.com/google/generative-ai-go v0.19.0 // indirect 38 | github.com/google/s2a-go v0.1.9 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 41 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 42 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 43 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 44 | github.com/mattn/go-colorable v0.1.13 // indirect 45 | github.com/mattn/go-isatty v0.0.20 // indirect 46 | github.com/sashabaranov/go-openai v1.36.1 // indirect 47 | github.com/tiktoken-go/tokenizer v0.2.1 // indirect 48 | github.com/yuin/gopher-lua v1.1.1 // indirect 49 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 50 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect 51 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 52 | go.opentelemetry.io/otel v1.34.0 // indirect 53 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 54 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 55 | go.starlark.net v0.0.0-20241125201518-c05ff208a98f // indirect 56 | golang.org/x/crypto v0.32.0 // indirect 57 | golang.org/x/net v0.34.0 // indirect 58 | golang.org/x/oauth2 v0.25.0 // indirect 59 | golang.org/x/sync v0.10.0 // indirect 60 | golang.org/x/sys v0.29.0 // indirect 61 | golang.org/x/text v0.21.0 // indirect 62 | google.golang.org/api v0.217.0 // indirect 63 | google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect 65 | google.golang.org/grpc v1.69.4 // indirect 66 | google.golang.org/protobuf v1.36.3 // indirect 67 | gopkg.in/yaml.v2 v2.4.0 // indirect 68 | ) 69 | -------------------------------------------------------------------------------- /metadata.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | // MergeStrategy defines how to handle metadata key conflicts 4 | type MergeStrategy int 5 | 6 | const ( 7 | // KeepExisting keeps the existing value on conflict 8 | KeepExisting MergeStrategy = iota 9 | // KeepNew overwrites with the new value on conflict 10 | KeepNew 11 | // Combine attempts to combine values (slice/map/string) 12 | Combine 13 | // Skip ignores conflicting keys 14 | Skip 15 | ) 16 | 17 | type Metadata map[string]any 18 | 19 | // Copy creates a deep copy of the metadata 20 | func (m Metadata) Copy() Metadata { 21 | copied := make(Metadata, len(m)) 22 | for k, v := range m { 23 | copied[k] = v 24 | } 25 | return copied 26 | } 27 | 28 | // Merge combines the current metadata with another, using the specified strategy 29 | func (m Metadata) Merge(other Metadata, strategy MergeStrategy) Metadata { 30 | return m.MergeWithCustom(other, strategy, nil) 31 | } 32 | 33 | // MergeWithCustom combines metadata with custom handlers for specific keys 34 | func (m Metadata) MergeWithCustom(other Metadata, strategy MergeStrategy, 35 | customMerge map[string]func(existing, new any) any) Metadata { 36 | 37 | result := m.Copy() 38 | 39 | for k, newVal := range other { 40 | // Check for custom merge function first 41 | if customMerge != nil { 42 | if mergeFn, exists := customMerge[k]; exists { 43 | if existingVal, hasKey := result[k]; hasKey { 44 | result[k] = mergeFn(existingVal, newVal) 45 | } else { 46 | result[k] = newVal 47 | } 48 | continue 49 | } 50 | } 51 | 52 | // Handle based on strategy if key exists 53 | if existingVal, exists := result[k]; exists { 54 | switch strategy { 55 | case KeepExisting: 56 | continue 57 | case KeepNew: 58 | result[k] = newVal 59 | case Combine: 60 | result[k] = combineValues(existingVal, newVal) 61 | case Skip: 62 | continue 63 | } 64 | } else { 65 | // No conflict, just add the new value 66 | result[k] = newVal 67 | } 68 | } 69 | 70 | return result 71 | } 72 | 73 | func combineValues(existing, new any) any { 74 | switch existingVal := existing.(type) { 75 | case []any: 76 | if newVal, ok := new.([]any); ok { 77 | combined := make([]any, len(existingVal)) 78 | copy(combined, existingVal) 79 | return append(combined, newVal...) 80 | } 81 | 82 | case map[string]any: 83 | if newVal, ok := new.(map[string]any); ok { 84 | combined := make(map[string]any) 85 | for k, v := range existingVal { 86 | combined[k] = v 87 | } 88 | for k, v := range newVal { 89 | combined[k] = v 90 | } 91 | return combined 92 | } 93 | 94 | case string: 95 | if newVal, ok := new.(string); ok { 96 | return existingVal + newVal 97 | } 98 | 99 | case int: 100 | if newVal, ok := new.(int); ok { 101 | return existingVal + newVal 102 | } 103 | 104 | case float64: 105 | if newVal, ok := new.(float64); ok { 106 | return existingVal + newVal 107 | } 108 | } 109 | 110 | // If types don't match or aren't combinable, return new value 111 | return new 112 | } 113 | -------------------------------------------------------------------------------- /handlers/summarize.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/chriscow/minds/handlers/summarizer" 9 | ) 10 | 11 | // summarizer is a MessageHandler that takes the list of messages passed to it 12 | // and prompts the LLM provider to summarizer the conversation so far. It returns 13 | // a single message with the system message appended with the current summary. 14 | type summarize struct { 15 | provider minds.ContentGenerator 16 | systemMsg string 17 | summary string 18 | opts summarizer.Options 19 | } 20 | 21 | // NewSummarizer creates a handler that maintains a running summary of thread messages. 22 | // 23 | // The handler prompts an LLM to generate a concise summary of all messages in the thread, 24 | // focusing on key information. The summary is appended to the system message in 25 | // XML tags and persists across handler invocations. 26 | // 27 | // Parameters: 28 | // - provider: LLM content generator for creating summaries. 29 | // - systemMsg: Initial system message to prepend to summaries. 30 | // 31 | // Returns: 32 | // - A handler that maintains thread summaries via LLM generation. 33 | // 34 | // Note: The original thread context is not modified; a new context with the 35 | // updated system message is created. 36 | func NewSummarizer(provider minds.ContentGenerator, systemMsg string, opts ...summarizer.Option) *summarize { 37 | s := &summarize{ 38 | provider: provider, 39 | systemMsg: systemMsg, 40 | opts: summarizer.Options{ 41 | Prompt: ` 42 | Your task is to create a concise running summary of responses and information 43 | in the provided text, focusing on key and potentially important information 44 | to remember. You will receive the current summary and your latest responses. 45 | Combine them, adding relevant key information from the latest development 46 | in 1st person past tense and keeping the summary concise.`, 47 | }, 48 | } 49 | 50 | for _, opt := range opts { 51 | opt(&s.opts) 52 | } 53 | 54 | return s 55 | } 56 | 57 | func (s *summarize) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 58 | ctx := tc.Context() 59 | messages, err := json.Marshal(tc.Messages()) 60 | if err != nil { 61 | return tc, err 62 | } 63 | 64 | prompt := fmt.Sprintf(` 65 | Current Summary: 66 | """ %s """ 67 | 68 | Latest Responses: 69 | """ %s """`, s.summary, string(messages)) 70 | 71 | req := minds.Request{ 72 | Messages: minds.Messages{ 73 | { 74 | Role: minds.RoleSystem, 75 | Content: s.opts.Prompt, 76 | }, 77 | { 78 | Role: minds.RoleUser, 79 | Content: prompt, 80 | }, 81 | }, 82 | } 83 | 84 | resp, err := s.provider.GenerateContent(ctx, req) 85 | if err != nil { 86 | return tc, err 87 | } 88 | s.summary = resp.String() 89 | 90 | tc = tc.WithMessages(minds.Message{ 91 | Role: minds.RoleSystem, 92 | Content: fmt.Sprintf("%s\n\n%s", s.systemMsg, s.summary), 93 | }) 94 | 95 | if next != nil { 96 | return next.HandleThread(tc, next) 97 | } 98 | 99 | return tc, err 100 | } 101 | -------------------------------------------------------------------------------- /_examples/tools-calculator/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/chriscow/minds" 9 | "github.com/chriscow/minds/providers/gemini" 10 | "github.com/chriscow/minds/providers/openai" 11 | "github.com/chriscow/minds/tools/calculator" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | const prompt = "calculate 3+7*4" 16 | 17 | var ( 18 | cyan = color.New(color.FgCyan).SprintFunc() 19 | green = color.New(color.FgGreen).SprintFunc() 20 | purple = color.New(color.FgHiMagenta).SprintFunc() 21 | ) 22 | 23 | func main() { 24 | ctx := context.Background() 25 | 26 | registry := minds.NewToolRegistry() 27 | calc, _ := calculator.NewCalculator(calculator.Starlark) 28 | registry.Register(calc) 29 | 30 | req := minds.Request{ 31 | Messages: minds.Messages{{Role: minds.RoleUser, Content: prompt}}, 32 | } 33 | 34 | withGemini(ctx, registry, req) 35 | withOpenAI(ctx, registry, req) 36 | withDeepSeek(ctx, registry, req) 37 | 38 | registry = minds.NewToolRegistry() 39 | calc, _ = calculator.NewCalculator(calculator.Lua) 40 | registry.Register(calc) 41 | 42 | withGemini(ctx, registry, req) 43 | withOpenAI(ctx, registry, req) 44 | withDeepSeek(ctx, registry, req) 45 | } 46 | 47 | func withGemini(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 48 | provider, err := gemini.NewProvider(ctx, gemini.WithToolRegistry(registry)) 49 | if err != nil { 50 | panic(err) 51 | } 52 | defer provider.Close() 53 | 54 | resp, err := provider.GenerateContent(ctx, req) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | printOutput(cyan("Gemini"), resp) 60 | } 61 | 62 | func withOpenAI(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 63 | provider, err := openai.NewProvider(openai.WithToolRegistry(registry)) 64 | if err != nil { 65 | panic(err) 66 | } 67 | defer provider.Close() 68 | 69 | resp, err := provider.GenerateContent(ctx, req) 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | printOutput(green("OpenAI"), resp) 75 | } 76 | 77 | func withDeepSeek(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 78 | baseURl := "https://api.deepseek.com" 79 | apiKey := os.Getenv("DEEPSEEK_API_KEY") 80 | model := "deepseek-chat" 81 | provider, err := openai.NewProvider( 82 | openai.WithAPIKey(apiKey), 83 | openai.WithModel(model), 84 | openai.WithToolRegistry(registry), 85 | openai.WithBaseURL(baseURl), 86 | ) 87 | if err != nil { 88 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 89 | return 90 | } 91 | defer provider.Close() 92 | 93 | resp, err := provider.GenerateContent(ctx, req) 94 | if err != nil { 95 | panic(err) 96 | } 97 | 98 | printOutput(purple("DeepSeek"), resp) 99 | } 100 | 101 | func printOutput(name string, resp minds.Response) { 102 | // 103 | // We should get a function call response 104 | // 105 | text := resp.String() 106 | if text != "" { 107 | fmt.Printf("[%s] Unexpected response: %s\n", name, text) 108 | } 109 | 110 | for _, call := range resp.ToolCalls() { 111 | fn := call.Function 112 | fmt.Printf("[%s] Called %s with args: %v\n", name, fn.Name, string(fn.Parameters)) 113 | fmt.Printf("[%s] Result: %v\n", name, string(fn.Result)) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /handlers/range.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // Range executes a handler multiple times with different values. For each value in 10 | // the provided list, the handler executes with the value stored in the thread 11 | // context's metadata under the key "range_value". The handler supports middleware 12 | // that will be applied to each iteration. 13 | type Range struct { 14 | name string 15 | handler minds.ThreadHandler 16 | values []any 17 | middleware []minds.Middleware 18 | } 19 | 20 | // NewRange creates a handler that processes a thread with a series of values. 21 | // 22 | // Parameters: 23 | // - name: Identifier for this range handler 24 | // - handler: The handler to execute for each value 25 | // - values: Values to iterate over 26 | // 27 | // Returns: 28 | // - A Range handler that processes the thread once for each value 29 | // 30 | // Example: 31 | // 32 | // rng := NewRange("process", 33 | // processHandler, 34 | // "value1", "value2", "value3", 35 | // ) 36 | func NewRange(name string, handler minds.ThreadHandler, values ...any) *Range { 37 | if handler == nil { 38 | panic(fmt.Sprintf("%s: handler cannot be nil", name)) 39 | } 40 | 41 | return &Range{ 42 | name: name, 43 | handler: handler, 44 | values: values, 45 | middleware: make([]minds.Middleware, 0), 46 | } 47 | } 48 | 49 | // Use adds middleware to the handler 50 | func (r *Range) Use(middleware ...minds.Middleware) { 51 | r.middleware = append(r.middleware, middleware...) 52 | } 53 | 54 | // With returns a new handler with the provided middleware 55 | func (r *Range) With(middleware ...minds.Middleware) minds.ThreadHandler { 56 | newRange := &Range{ 57 | name: r.name, 58 | handler: r.handler, 59 | values: r.values, 60 | middleware: append([]minds.Middleware{}, r.middleware...), 61 | } 62 | newRange.Use(middleware...) 63 | return newRange 64 | } 65 | 66 | // HandleThread implements the ThreadHandler interface 67 | func (r *Range) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 68 | current := tc 69 | 70 | for _, value := range r.values { 71 | if current.Context().Err() != nil { 72 | return current, current.Context().Err() 73 | } 74 | 75 | // Setup iteration context with value 76 | meta := current.Metadata() 77 | meta["range_value"] = value 78 | current = current.WithMetadata(meta) 79 | 80 | // Create wrapped handler for this iteration 81 | wrappedHandler := r.handler 82 | 83 | // Apply middleware in reverse order for proper nesting 84 | for i := len(r.middleware) - 1; i >= 0; i-- { 85 | wrappedHandler = r.middleware[i].Wrap(wrappedHandler) 86 | } 87 | 88 | var err error 89 | current, err = wrappedHandler.HandleThread(current, nil) 90 | if err != nil { 91 | return current, fmt.Errorf("%s: %w", r.name, err) 92 | } 93 | } 94 | 95 | // If there's a next handler, execute it with the final context 96 | if next != nil { 97 | return next.HandleThread(current, nil) 98 | } 99 | 100 | return current, nil 101 | } 102 | 103 | // String returns a string representation of the Range handler 104 | func (r *Range) String() string { 105 | return fmt.Sprintf("Range(%s, %d values)", r.name, len(r.values)) 106 | } 107 | -------------------------------------------------------------------------------- /_examples/chat-structured-output/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/providers/gemini" 11 | "github.com/chriscow/minds/providers/openai" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | const prompt = "Generate sample data for a person in JSON format" 16 | 17 | var ( 18 | cyan = color.New(color.FgCyan).SprintFunc() 19 | green = color.New(color.FgGreen).SprintFunc() 20 | purple = color.New(color.FgHiMagenta).SprintFunc() 21 | ) 22 | 23 | type SampleData struct { 24 | Name string `json:"name"` 25 | Age int `json:"age"` 26 | } 27 | 28 | // This example demonstrates how to generate structured output from an LLM. 29 | // The structured output is defined by the SampleData struct above. 30 | // The example uses two different LLM providers, Gemini and OpenAI, to generate the same output. 31 | func main() { 32 | ctx := context.Background() 33 | req := minds.Request{Messages: minds.Messages{{Role: minds.RoleUser, Content: prompt}}} 34 | withGemini(ctx, req) 35 | withOpenAI(ctx, req) 36 | withDeepSeek(ctx, req) 37 | } 38 | 39 | func withGemini(ctx context.Context, req minds.Request) { 40 | schema, _ := gemini.GenerateSchema(SampleData{}) 41 | llm, err := gemini.NewProvider(ctx, gemini.WithResponseSchema(schema)) 42 | if err != nil { 43 | fmt.Printf("[%s] error: %v", cyan("Gemini"), err) 44 | return 45 | } 46 | 47 | resp, err := llm.GenerateContent(ctx, req) 48 | if err != nil { 49 | fmt.Printf("[%s] error: %v", cyan("Gemini"), err) 50 | return 51 | } 52 | printResult(cyan("Gemini"), resp) 53 | } 54 | 55 | func withOpenAI(ctx context.Context, req minds.Request) { 56 | schema, _ := minds.NewResponseSchema("SampleData", "some sample data", SampleData{}) 57 | llm, err := openai.NewProvider(openai.WithResponseSchema(*schema)) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | resp, err := llm.GenerateContent(ctx, req) 63 | if err != nil { 64 | fmt.Printf("[%s] error: %v", green("OpenAI"), err) 65 | return 66 | } 67 | 68 | printResult(green("OpenAI"), resp) 69 | } 70 | 71 | func withDeepSeek(ctx context.Context, req minds.Request) { 72 | yellow := color.New(color.FgYellow).SprintFunc() 73 | fmt.Printf("[%s] %s\n", purple("DeepSeek"), yellow("WARNING: DeepSeek does not support structured output in this example")) 74 | 75 | baseURl := "https://api.deepseek.com" 76 | apiKey := os.Getenv("DEEPSEEK_API_KEY") 77 | model := "deepseek-chat" 78 | schema, _ := minds.NewResponseSchema("SampleData", "some sample data", SampleData{}) 79 | 80 | llm, err := openai.NewProvider( 81 | openai.WithAPIKey(apiKey), 82 | openai.WithModel(model), 83 | openai.WithBaseURL(baseURl), 84 | openai.WithResponseSchema(*schema), 85 | ) 86 | if err != nil { 87 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 88 | return 89 | } 90 | 91 | resp, err := llm.GenerateContent(ctx, req) 92 | if err != nil { 93 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 94 | return 95 | } 96 | 97 | printResult("DeepSeek", resp) 98 | } 99 | 100 | func printResult(name string, resp minds.Response) { 101 | var result SampleData 102 | json.Unmarshal([]byte(resp.String()), &result) 103 | 104 | fmt.Printf("[%s] Name: %s, Age: %d\n", name, result.Name, result.Age) 105 | } 106 | -------------------------------------------------------------------------------- /providers/gemini/schema.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | 8 | "github.com/google/generative-ai-go/genai" 9 | ) 10 | 11 | func GenerateSchema(v any) (*genai.Schema, error) { 12 | d, err := minds.GenerateSchema(v) 13 | if err != nil { 14 | return nil, fmt.Errorf("error generating schema: %w", err) 15 | } 16 | 17 | if d == nil { 18 | return nil, fmt.Errorf("definition cannot be nil") 19 | } 20 | 21 | return convertSchema(*d) 22 | } 23 | 24 | func convertSchema(d minds.Definition) (*genai.Schema, error) { 25 | schema := &genai.Schema{} 26 | 27 | // Map the type 28 | switch d.Type { 29 | case minds.String: 30 | schema.Type = genai.TypeString 31 | case minds.Number: 32 | schema.Type = genai.TypeNumber 33 | case minds.Integer: 34 | schema.Type = genai.TypeInteger 35 | case minds.Boolean: 36 | schema.Type = genai.TypeBoolean 37 | case minds.Array: 38 | schema.Type = genai.TypeArray 39 | if d.Items != nil { 40 | items, err := convertSchema(*d.Items) 41 | if err != nil { 42 | return nil, fmt.Errorf("error converting array items: %w", err) 43 | } 44 | schema.Items = items 45 | } 46 | case minds.Object: 47 | schema.Type = genai.TypeObject 48 | if len(d.Properties) > 0 { 49 | schema.Properties = make(map[string]*genai.Schema) 50 | for key, prop := range d.Properties { 51 | propSchema, err := convertSchema(prop) 52 | if err != nil { 53 | return nil, fmt.Errorf("error converting property %s: %w", key, err) 54 | } 55 | schema.Properties[key] = propSchema 56 | } 57 | } 58 | if len(d.Required) > 0 { 59 | schema.Required = d.Required 60 | } 61 | case minds.Null: 62 | schema.Type = genai.TypeUnspecified 63 | schema.Nullable = true 64 | default: 65 | return nil, fmt.Errorf("unsupported type: %s", d.Type) 66 | } 67 | 68 | // Copy description if present 69 | if d.Description != "" { 70 | schema.Description = d.Description 71 | } 72 | 73 | // Copy enum values if present 74 | if len(d.Enum) > 0 { 75 | schema.Enum = d.Enum 76 | } 77 | 78 | return schema, nil 79 | } 80 | 81 | func reflectMindsDefinition(schema *genai.Schema) (*minds.Definition, error) { 82 | definition := &minds.Definition{} 83 | 84 | // Map the type 85 | switch schema.Type { 86 | case genai.TypeString: 87 | definition.Type = minds.String 88 | case genai.TypeNumber: 89 | definition.Type = minds.Number 90 | case genai.TypeInteger: 91 | definition.Type = minds.Integer 92 | case genai.TypeBoolean: 93 | definition.Type = minds.Boolean 94 | case genai.TypeArray: 95 | definition.Type = minds.Array 96 | if schema.Items != nil { 97 | items, err := reflectMindsDefinition(schema.Items) 98 | if err != nil { 99 | return nil, fmt.Errorf("error converting array items: %w", err) 100 | } 101 | definition.Items = items 102 | } 103 | case genai.TypeObject: 104 | definition.Type = minds.Object 105 | if len(schema.Properties) > 0 { 106 | definition.Properties = make(map[string]minds.Definition) 107 | for key, propSchema := range schema.Properties { 108 | propDefinition, err := reflectMindsDefinition(propSchema) 109 | if err != nil { 110 | return nil, fmt.Errorf("error converting property %s: %w", key, err) 111 | } 112 | definition.Properties[key] = *propDefinition 113 | } 114 | } 115 | } 116 | 117 | return definition, nil 118 | } 119 | -------------------------------------------------------------------------------- /middleware/retry.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/chriscow/minds/middleware/retry" 9 | ) 10 | 11 | // Retry creates a middleware that provides automatic retry functionality for handlers. 12 | // 13 | // The middleware offers configurable retry behavior, including: 14 | // - Customizable number of retry attempts 15 | // - Flexible backoff strategies 16 | // - Configurable retry criteria 17 | // - Optional timeout propagation 18 | // 19 | // Default behavior: 20 | // - 3 retry attempts 21 | // - No delay between attempts 22 | // - Retries on any error 23 | // - Timeout propagation enabled 24 | // 25 | // Example usage: 26 | // 27 | // flow.Use(Retry("api-retry", 28 | // retry.WithAttempts(5), 29 | // retry.WithBackoff(retry.DefaultBackoff(time.Second)), 30 | // retry.WithRetryCriteria(func(err error) bool { 31 | // return errors.Is(err, io.ErrTemporary) 32 | // }), 33 | // )) 34 | // 35 | // The middleware will stop retrying if: 36 | // - An attempt succeeds 37 | // - Maximum attempts are reached 38 | // - Retry criteria returns false 39 | // - Context is canceled (if timeout propagation is enabled) 40 | func Retry(name string, opts ...retry.Option) minds.Middleware { 41 | return minds.MiddlewareFunc(func(next minds.ThreadHandler) minds.ThreadHandler { 42 | return &retryMiddleware{ 43 | name: name, 44 | next: next, 45 | config: configureRetry(opts...), 46 | } 47 | }) 48 | } 49 | 50 | // retryMiddleware wraps a handler with retry logic. 51 | type retryMiddleware struct { 52 | name string 53 | next minds.ThreadHandler 54 | config *retry.Options 55 | } 56 | 57 | // Wrap applies the retry behavior to a handler. 58 | func (r *retryMiddleware) Wrap(next minds.ThreadHandler) minds.ThreadHandler { 59 | return &retryMiddleware{ 60 | name: r.name, 61 | next: next, 62 | config: r.config, 63 | } 64 | } 65 | 66 | // HandleThread executes the handler, retrying on failure. 67 | func (r *retryMiddleware) HandleThread(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 68 | if r.config.Attempts <= 0 { 69 | return r.next.HandleThread(tc, nil) 70 | } 71 | 72 | var lastErr error 73 | 74 | for attempt := 0; attempt < r.config.Attempts; attempt++ { 75 | // Handle timeout propagation 76 | if r.config.PropagateTimeout { 77 | select { 78 | case <-tc.Context().Done(): 79 | return tc, fmt.Errorf("%s: context canceled: %w", r.name, tc.Context().Err()) 80 | default: 81 | } 82 | } 83 | 84 | // Attempt execution 85 | result, err := r.next.HandleThread(tc, nil) 86 | if err == nil { 87 | return result, nil 88 | } 89 | 90 | // Stop retrying if error does not meet retry criteria 91 | if !r.config.ShouldRetry(tc, attempt, err) { 92 | return tc, fmt.Errorf("%s: retry stopped due to error: %w", r.name, err) 93 | } 94 | 95 | lastErr = err 96 | 97 | // Apply backoff strategy if defined 98 | if r.config.Backoff != nil { 99 | time.Sleep(r.config.Backoff(attempt)) 100 | } 101 | } 102 | 103 | // Return last error if all attempts fail 104 | return tc, fmt.Errorf("%s: all %d attempts failed, last error: %w", r.name, r.config.Attempts, lastErr) 105 | } 106 | 107 | // configureRetry applies provided options to a retry configuration. 108 | func configureRetry(opts ...retry.Option) *retry.Options { 109 | config := retry.NewDefaultOptions() 110 | for _, opt := range opts { 111 | opt(config) 112 | } 113 | return config 114 | } 115 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/matryer/is" 9 | ) 10 | 11 | func TestThreadContext(t *testing.T) { 12 | is := is.New(t) 13 | 14 | t.Run("NewThreadContext", func(t *testing.T) { 15 | is := is.New(t) 16 | ctx := context.Background() 17 | tc := NewThreadContext(ctx) 18 | 19 | is.True(tc != nil) 20 | is.Equal(ctx, tc.Context()) 21 | is.True(tc.UUID() != "") 22 | is.Equal(len(tc.Messages()), 0) 23 | is.Equal(len(tc.Metadata()), 0) 24 | }) 25 | 26 | t.Run("AppendMessage", func(t *testing.T) { 27 | is := is.New(t) 28 | tc := NewThreadContext(context.Background()) 29 | msg := Message{Content: "test"} 30 | 31 | is.True(len(tc.Messages()) == 0) 32 | 33 | tc.AppendMessages(msg) 34 | msgs := tc.Messages() 35 | 36 | is.Equal(len(msgs), 1) 37 | is.Equal(msgs[0].Content, msg.Content) 38 | }) 39 | 40 | t.Run("WithContext", func(t *testing.T) { 41 | is := is.New(t) 42 | oldCtx := context.Background() 43 | tc := NewThreadContext(oldCtx) 44 | 45 | newCtx, cancel := context.WithTimeout(context.Background(), time.Second) 46 | defer cancel() 47 | 48 | newTc := tc.WithContext(newCtx) 49 | 50 | is.True(tc != newTc) 51 | is.Equal(newTc.Context(), newCtx) 52 | is.Equal(newTc.UUID(), tc.UUID()) 53 | is.Equal(newTc.Messages(), tc.Messages()) 54 | is.Equal(newTc.Metadata(), tc.Metadata()) 55 | }) 56 | 57 | t.Run("WithUUID", func(t *testing.T) { 58 | is := is.New(t) 59 | tc := NewThreadContext(context.Background()) 60 | newUUID := "test-uuid" 61 | 62 | newTc := tc.WithUUID(newUUID) 63 | 64 | is.True(tc != newTc) 65 | is.Equal(newTc.UUID(), newUUID) 66 | is.Equal(newTc.Context(), tc.Context()) 67 | is.Equal(newTc.Messages(), tc.Messages()) 68 | is.Equal(newTc.Metadata(), tc.Metadata()) 69 | }) 70 | 71 | t.Run("WithMessages", func(t *testing.T) { 72 | is := is.New(t) 73 | tc := NewThreadContext(context.Background()) 74 | msgs := Messages{{Content: "test1"}, {Content: "test2"}} 75 | 76 | newTc := tc.WithMessages(msgs...) 77 | 78 | is.True(tc != newTc) 79 | resultMsgs := newTc.Messages() 80 | is.Equal(len(resultMsgs), len(msgs)) 81 | for i := range msgs { 82 | is.Equal(resultMsgs[i].Content, msgs[i].Content) 83 | } 84 | is.Equal(newTc.Context(), tc.Context()) 85 | is.Equal(newTc.UUID(), tc.UUID()) 86 | is.Equal(newTc.Metadata(), tc.Metadata()) 87 | }) 88 | 89 | t.Run("WithMetadata", func(t *testing.T) { 90 | is := is.New(t) 91 | tc := NewThreadContext(context.Background()) 92 | metadata := Metadata{"key": "value"} 93 | 94 | newTc := tc.WithMetadata(metadata) 95 | 96 | is.True(tc != newTc) 97 | is.Equal(newTc.Metadata(), metadata) 98 | is.Equal(newTc.Context(), tc.Context()) 99 | is.Equal(newTc.UUID(), tc.UUID()) 100 | is.Equal(newTc.Messages(), tc.Messages()) 101 | }) 102 | 103 | t.Run("Concurrency", func(t *testing.T) { 104 | is := is.New(t) 105 | tc := NewThreadContext(context.Background()) 106 | done := make(chan bool) 107 | 108 | go func() { 109 | for i := 0; i < 100; i++ { 110 | tc.AppendMessages(Message{Content: "test"}) 111 | } 112 | done <- true 113 | }() 114 | 115 | go func() { 116 | var msgs Messages 117 | for i := 0; i < 100; i++ { 118 | msgs = tc.Messages() 119 | } 120 | is.True(len(msgs) >= 0) // Verify we can read messages 121 | done <- true 122 | }() 123 | 124 | <-done 125 | <-done 126 | 127 | finalMsgs := tc.Messages() 128 | is.True(len(finalMsgs) > 0) // Verify messages were actually appended 129 | }) 130 | } 131 | -------------------------------------------------------------------------------- /_examples/middleware-ratelimit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "time" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/handlers" 11 | "github.com/chriscow/minds/providers/gemini" 12 | "github.com/chriscow/minds/providers/openai" 13 | "github.com/fatih/color" 14 | "golang.org/x/time/rate" 15 | ) 16 | 17 | // The example demonstrates how to create a rate limiter middleware for a 18 | // joke-telling competition between two language models (LLMs). The rate limiter 19 | // allows one joke every 5 seconds. The example uses the Gemini and OpenAI LLMs 20 | // to exchange jokes in a ping-pong fashion. The rate limiter ensures that the 21 | // joke exchange is doesn't exceed the rate limit. 22 | // 23 | // You could easily add a `handlers.Must` handler to ensure that the jokes are 24 | // family-friendly and clean. The `handlers.Must` handler would cancel the 25 | // joke exchange if any joke is inappropriate. 26 | 27 | // RateLimiter provides rate limiting for thread handlers 28 | type RateLimiter struct { 29 | limiter *rate.Limiter 30 | name string 31 | } 32 | 33 | // NewRateLimiter creates a rate limiter that allows 'n' requests per duration 34 | func NewRateLimiter(name string, n int, d time.Duration) *RateLimiter { 35 | return &RateLimiter{ 36 | name: name, 37 | limiter: rate.NewLimiter(rate.Every(d/time.Duration(n)), n), 38 | } 39 | } 40 | 41 | // HandleThread implements the ThreadHandler interface 42 | func (r *RateLimiter) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 43 | // Wait for rate limiter 44 | if err := r.limiter.Wait(tc.Context()); err != nil { 45 | return tc, fmt.Errorf("rate limit exceeded: %w", err) 46 | } 47 | 48 | // Pass thread to next handler if we haven't exceeded the rate limit 49 | if next != nil { 50 | return next.HandleThread(tc, nil) 51 | } 52 | 53 | return tc, nil 54 | } 55 | 56 | func main() { 57 | ctx := context.Background() 58 | cyan := color.New(color.FgCyan).SprintFunc() 59 | green := color.New(color.FgGreen).SprintFunc() 60 | sysPrompt := `Your name is %s. You enjoy telling jokes and enjoy joke competitions. Prefix your responses with your name in this format: [%s]. If you hear a joke from the user, rate it 1 to 5. Then reply with a joke of your own.` 61 | 62 | geminiJoker, err := gemini.NewProvider(ctx, gemini.WithSystemPrompt(fmt.Sprintf(sysPrompt, cyan("Gemini"), cyan("Gemini")))) 63 | if err != nil { 64 | log.Fatalf("Error creating Gemini provider: %v", err) 65 | } 66 | 67 | openAIJoker, err := openai.NewProvider(openai.WithSystemPrompt(fmt.Sprintf(sysPrompt, green("OpenAI"), green("OpenAI")))) 68 | if err != nil { 69 | log.Fatalf("Error creating OpenAI provider: %v", err) 70 | } 71 | 72 | // Create a rate limiter that allows 1 request every 5 seconds 73 | limiter := NewRateLimiter("rate_limiter", 1, 5*time.Second) 74 | 75 | printJoke := minds.ThreadHandlerFunc(func(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 76 | fmt.Printf("%s\n", tc.Messages().Last().Content) 77 | return tc, nil 78 | }) 79 | 80 | round := handlers.NewSequence("joke_round", geminiJoker, printJoke, openAIJoker, printJoke) 81 | jokeCompetition := handlers.NewFor("joke_exchange", 5, round, nil) 82 | jokeCompetition.Use(limiter) 83 | 84 | // Initial prompt 85 | initialThread := minds.NewThreadContext(ctx).WithMessages(minds.Message{ 86 | Role: minds.RoleUser, 87 | Content: "You are in a joke telling contest. You go first.", 88 | }) 89 | 90 | // Let them exchange jokes until context is canceled 91 | if _, err := jokeCompetition.HandleThread(initialThread, nil); err != nil { 92 | log.Fatalf("Error in joke exchange: %v", err) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /handlers/if.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // If represents a conditional handler that executes one of two handlers based on 10 | // a condition evaluation. It's a simplified version of Switch for binary conditions. 11 | type If struct { 12 | name string 13 | condition SwitchCondition 14 | trueHandler minds.ThreadHandler 15 | fallbackHandler minds.ThreadHandler 16 | middleware []minds.Middleware 17 | } 18 | 19 | // NewIf creates a new If handler that executes trueHandler when the condition evaluates 20 | // to true, otherwise executes fallbackHandler if provided. 21 | // 22 | // Parameters: 23 | // - name: Identifier for this conditional handler 24 | // - condition: The condition to evaluate 25 | // - trueHandler: Handler to execute when condition is true 26 | // - fallbackHandler: Optional handler to execute when condition is false 27 | // 28 | // Returns: 29 | // - A handler that implements conditional execution based on the provided condition 30 | // 31 | // Example: 32 | // 33 | // metadata := MetadataEquals{Key: "type", Value: "question"} 34 | // questionHandler := SomeQuestionHandler() 35 | // defaultHandler := DefaultHandler() 36 | // 37 | // ih := NewIf("type-check", metadata, questionHandler, defaultHandler) 38 | func NewIf(name string, condition SwitchCondition, trueHandler minds.ThreadHandler, fallbackHandler minds.ThreadHandler) *If { 39 | return &If{ 40 | name: name, 41 | condition: condition, 42 | trueHandler: trueHandler, 43 | fallbackHandler: fallbackHandler, 44 | middleware: make([]minds.Middleware, 0), 45 | } 46 | } 47 | 48 | // Use adds middleware to the handler 49 | func (h *If) Use(middleware ...minds.Middleware) { 50 | h.middleware = append(h.middleware, middleware...) 51 | } 52 | 53 | // With returns a new handler with the provided middleware 54 | func (h *If) With(middleware ...minds.Middleware) minds.ThreadHandler { 55 | newIf := &If{ 56 | name: h.name, 57 | condition: h.condition, 58 | trueHandler: h.trueHandler, 59 | fallbackHandler: h.fallbackHandler, 60 | middleware: append([]minds.Middleware{}, h.middleware...), 61 | } 62 | newIf.Use(middleware...) 63 | return newIf 64 | } 65 | 66 | // HandleThread implements the ThreadHandler interface 67 | func (h *If) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 68 | // Evaluate the condition 69 | matches, err := h.condition.Evaluate(tc) 70 | if err != nil { 71 | return tc, fmt.Errorf("%s: error evaluating condition: %w", h.name, err) 72 | } 73 | 74 | // Determine which handler to execute 75 | var handlerToExecute minds.ThreadHandler 76 | if matches && h.trueHandler != nil { 77 | handlerToExecute = h.trueHandler 78 | } else if !matches && h.fallbackHandler != nil { 79 | handlerToExecute = h.fallbackHandler 80 | } 81 | 82 | // If we have a handler to execute, wrap it with middleware and run it 83 | if handlerToExecute != nil { 84 | // Create wrapped handler 85 | wrappedHandler := handlerToExecute 86 | 87 | // Apply middleware in reverse order for proper nesting 88 | for i := len(h.middleware) - 1; i >= 0; i-- { 89 | wrappedHandler = h.middleware[i].Wrap(wrappedHandler) 90 | } 91 | 92 | return wrappedHandler.HandleThread(tc, next) 93 | } 94 | 95 | // No handler to execute, call next if provided 96 | if next != nil { 97 | return next.HandleThread(tc, nil) 98 | } 99 | 100 | return tc, nil 101 | } 102 | 103 | // String returns a string representation of the If handler 104 | func (h *If) String() string { 105 | return fmt.Sprintf("If(%s)", h.name) 106 | } 107 | -------------------------------------------------------------------------------- /_examples/function-calling/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/providers/gemini" 11 | "github.com/chriscow/minds/providers/openai" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | const prompt = `Make the room cozy and warm` 16 | 17 | var ( 18 | cyan = color.New(color.FgCyan).SprintFunc() 19 | green = color.New(color.FgGreen).SprintFunc() 20 | purple = color.New(color.FgHiMagenta).SprintFunc() 21 | ) 22 | 23 | // Function calling requires a struct to define the parameters 24 | type LightControlParams struct { 25 | Brightness int `json:"brightness" description:"Light level from 0 to 100"` 26 | ColorTemp string `json:"colorTemp" description:"Color temperature (daylight/cool/warm)"` 27 | } 28 | 29 | func controlLight(_ context.Context, args []byte) ([]byte, error) { 30 | var params LightControlParams 31 | if err := json.Unmarshal(args, ¶ms); err != nil { 32 | return nil, err 33 | } 34 | 35 | result := map[string]any{ 36 | "brightness": params.Brightness, 37 | "colorTemp": params.ColorTemp, 38 | } 39 | 40 | return json.Marshal(result) 41 | } 42 | 43 | func main() { 44 | ctx := context.Background() 45 | 46 | // 47 | // Functions need to be wrapped with metadata to turn them into a tool 48 | // 49 | lightControl, err := minds.WrapFunction( 50 | "control_light", // Google recommends using snake_case for function names with Gemini 51 | "Set the brightness and color temperature of a room light", 52 | LightControlParams{}, 53 | controlLight, 54 | ) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | req := minds.Request{ 60 | Messages: minds.Messages{{Role: minds.RoleUser, Content: prompt}}, 61 | } 62 | 63 | withGemini(ctx, lightControl, req) 64 | withOpenAI(ctx, lightControl, req) 65 | withDeepSeek(ctx, lightControl, req) 66 | } 67 | 68 | func withGemini(ctx context.Context, fn minds.Tool, req minds.Request) { 69 | provider, err := gemini.NewProvider(ctx, gemini.WithTool(fn)) 70 | if err != nil { 71 | fmt.Printf("[%s] error: %v", cyan("Gemini"), err) 72 | return 73 | } 74 | defer provider.Close() 75 | 76 | resp, err := provider.GenerateContent(ctx, req) 77 | if err != nil { 78 | fmt.Printf("[%s] error: %v", cyan("Gemini"), err) 79 | return 80 | } 81 | 82 | printOutput(cyan("Gemini"), resp) 83 | } 84 | 85 | func withOpenAI(ctx context.Context, fn minds.Tool, req minds.Request) { 86 | provider, err := openai.NewProvider(openai.WithTool(fn)) 87 | if err != nil { 88 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 89 | return 90 | } 91 | defer provider.Close() 92 | 93 | resp, err := provider.GenerateContent(ctx, req) 94 | if err != nil { 95 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 96 | return 97 | } 98 | 99 | printOutput(green("OpenAI"), resp) 100 | } 101 | 102 | func withDeepSeek(ctx context.Context, fn minds.Tool, req minds.Request) { 103 | baseURl := "https://api.deepseek.com" 104 | apiKey := os.Getenv("DEEPSEEK_API_KEY") 105 | model := "deepseek-chat" 106 | provider, err := openai.NewProvider(openai.WithAPIKey(apiKey), openai.WithModel(model), openai.WithTool(fn), openai.WithBaseURL(baseURl)) 107 | if err != nil { 108 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 109 | return 110 | } 111 | defer provider.Close() 112 | 113 | resp, err := provider.GenerateContent(ctx, req) 114 | if err != nil { 115 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 116 | return 117 | } 118 | 119 | printOutput(purple("DeepSeek"), resp) 120 | } 121 | 122 | func printOutput(name string, resp minds.Response) { 123 | 124 | for _, call := range resp.ToolCalls() { 125 | fn := call.Function 126 | fmt.Printf("[%s] Called %s with args: %v\n", name, fn.Name, string(fn.Parameters)) 127 | fmt.Printf("[%s] Result: %v\n", name, string(fn.Result)) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package minds 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/google/uuid" 8 | ) 9 | 10 | type ThreadContext interface { 11 | // Clone returns a deep copy of the ThreadContext. 12 | Clone() ThreadContext 13 | Context() context.Context 14 | UUID() string 15 | 16 | // Messages returns a copy of the messages in the context. 17 | Messages() Messages 18 | 19 | // Metadata returns a copy of the metadata in the context. 20 | Metadata() Metadata 21 | 22 | AppendMessages(message ...Message) 23 | 24 | // SetKeyValue sets a key-value pair in the metadata. 25 | SetKeyValue(key string, value any) 26 | 27 | // WithContext returns a new ThreadContext with the provided context. 28 | WithContext(ctx context.Context) ThreadContext 29 | 30 | // WithUUID returns a new ThreadContext with the provided UUID. 31 | WithUUID(uuid string) ThreadContext 32 | 33 | // WithMessages returns a new ThreadContext with the provided messages. 34 | WithMessages(message ...Message) ThreadContext 35 | 36 | // WithMetadata returns a new ThreadContext with the provided metadata. 37 | WithMetadata(metadata Metadata) ThreadContext 38 | } 39 | 40 | type threadContext struct { 41 | mu sync.RWMutex 42 | ctx context.Context 43 | uuid string 44 | metadata Metadata 45 | messages Messages 46 | } 47 | 48 | func NewThreadContext(ctx context.Context) ThreadContext { 49 | return &threadContext{ 50 | ctx: ctx, 51 | uuid: uuid.New().String(), 52 | metadata: Metadata{}, 53 | messages: Messages{}, 54 | } 55 | } 56 | 57 | func (tc *threadContext) AppendMessages(messages ...Message) { 58 | tc.mu.Lock() 59 | defer tc.mu.Unlock() 60 | newMessages := tc.messages.Copy() 61 | tc.messages = append(newMessages, messages...) 62 | } 63 | 64 | func (tc *threadContext) Clone() ThreadContext { 65 | tc.mu.RLock() 66 | defer tc.mu.RUnlock() 67 | return &threadContext{ 68 | ctx: tc.ctx, 69 | uuid: tc.uuid, 70 | metadata: tc.metadata, 71 | messages: tc.messages, 72 | } 73 | } 74 | 75 | func (tc *threadContext) Context() context.Context { 76 | return tc.ctx 77 | } 78 | 79 | func (tc *threadContext) UUID() string { 80 | return tc.uuid 81 | } 82 | 83 | func (tc *threadContext) Messages() Messages { 84 | return tc.messages.Copy() 85 | } 86 | 87 | func (tc *threadContext) Metadata() Metadata { 88 | return tc.metadata.Copy() 89 | } 90 | 91 | // SetKeyValue sets a key-value pair in the metadata using a copy-on-write strategy. 92 | func (tc *threadContext) SetKeyValue(key string, value any) { 93 | tc.mu.Lock() 94 | defer tc.mu.Unlock() 95 | meta := tc.metadata.Copy() 96 | meta[key] = value 97 | tc.metadata = meta 98 | } 99 | 100 | // WithContext returns a cloned ThreadContext with the provided context. 101 | func (tc *threadContext) WithContext(ctx context.Context) ThreadContext { 102 | return &threadContext{ 103 | ctx: ctx, 104 | uuid: tc.UUID(), 105 | metadata: tc.metadata.Copy(), 106 | messages: tc.messages.Copy(), 107 | } 108 | } 109 | 110 | // WithUUID returns a cloned ThreadContext with the provided UUID. 111 | func (tc *threadContext) WithUUID(uuid string) ThreadContext { 112 | return &threadContext{ 113 | ctx: tc.Context(), 114 | uuid: uuid, 115 | metadata: tc.metadata.Copy(), 116 | messages: tc.messages.Copy(), 117 | } 118 | } 119 | 120 | // WithMessages returns a cloned ThreadContext with the provided messages. 121 | func (tc *threadContext) WithMessages(messages ...Message) ThreadContext { 122 | return &threadContext{ 123 | ctx: tc.Context(), 124 | uuid: tc.UUID(), 125 | metadata: tc.metadata, 126 | messages: messages, 127 | } 128 | } 129 | 130 | // WithMetadata returns a cloned ThreadContext with the provided metadata. 131 | func (tc *threadContext) WithMetadata(metadata Metadata) ThreadContext { 132 | return &threadContext{ 133 | ctx: tc.Context(), 134 | uuid: tc.UUID(), 135 | metadata: metadata, 136 | messages: tc.messages, 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /function.go: -------------------------------------------------------------------------------- 1 | // minds/interfaces.go 2 | 3 | package minds 4 | 5 | import ( 6 | "context" 7 | "fmt" 8 | "reflect" 9 | ) 10 | 11 | type FunctionCall struct { 12 | Name string `json:"name,omitempty"` 13 | Description string `json:"description,omitempty"` 14 | // call function with arguments in JSON format 15 | Parameters []byte `json:"parameters,omitempty"` 16 | Result []byte `json:"result,omitempty"` 17 | } 18 | 19 | type CallableFunc func(context.Context, []byte) ([]byte, error) 20 | 21 | // functionWrapper provides a convenient way to wrap Go functions with metadata 22 | type functionWrapper struct { 23 | name string 24 | description string 25 | argsSchema Definition 26 | impl CallableFunc // The actual function to call 27 | } 28 | 29 | // WrapFunction takes a `CallableFunc` and wraps it as a `minds.Tool` with the provided name and description. 30 | func WrapFunction(name, description string, args any, fn CallableFunc) (*functionWrapper, error) { 31 | if args == nil { 32 | return nil, fmt.Errorf("args must be a non-nil pointer to a struct") 33 | } 34 | 35 | // Validate that fn is actually a function 36 | fnType := reflect.TypeOf(args) 37 | 38 | // Generate parameter schema from the function's first parameter 39 | params, err := GenerateSchema(reflect.New(fnType).Interface()) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | if !isValidToolName(name) { 45 | return nil, fmt.Errorf("invalid function name: `%s`. Must start with a letter or an underscore. Must be alphameric (a-z, A-Z, 0-9), underscores (_), dots (.) or dashes (-), with a maximum length of 64", name) 46 | } 47 | 48 | return &functionWrapper{ 49 | name: name, 50 | description: description, 51 | argsSchema: *params, 52 | impl: fn, 53 | }, nil 54 | } 55 | 56 | func isValidToolName(name string) bool { 57 | if len(name) == 0 { 58 | return false 59 | } 60 | 61 | if len(name) > 64 { 62 | return false 63 | } 64 | 65 | if !isAlphaNumeric(rune(name[0])) { 66 | return false 67 | } 68 | 69 | for _, c := range name { 70 | if !isAlphaNumeric(c) && c != '_' && c != '.' && c != '-' { 71 | return false 72 | } 73 | } 74 | 75 | return true 76 | } 77 | 78 | func isAlphaNumeric(c rune) bool { 79 | return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') 80 | } 81 | 82 | func (f *functionWrapper) Type() string { return "function" } 83 | func (f *functionWrapper) Name() string { return f.name } 84 | func (f *functionWrapper) Description() string { return f.description } 85 | func (f *functionWrapper) Parameters() Definition { return f.argsSchema } 86 | 87 | func (f *functionWrapper) Call(ctx context.Context, params []byte) ([]byte, error) { 88 | return f.impl(ctx, params) 89 | } 90 | 91 | func (f *functionWrapper) HandleThread(ctx ThreadContext, next ThreadHandler) (ThreadContext, error) { 92 | return ctx, nil 93 | } 94 | 95 | // HandleFunctionCalls takes an array of ToolCalls and executes the functions they represent 96 | // using the provided ToolRegistry. It returns an array of ToolCalls with the results of the function calls. 97 | func HandleFunctionCalls(ctx context.Context, calls []ToolCall, registry ToolRegistry) ([]ToolCall, error) { 98 | for i, call := range calls { 99 | if ctx.Err() != nil { 100 | return nil, ctx.Err() 101 | } 102 | fn := call.Function 103 | f, ok := registry.Lookup(fn.Name) 104 | if !ok { 105 | // The tool was not found. Return a string telling the LLM what tools are available 106 | tools := registry.List() 107 | names := make([]string, 0, len(tools)) 108 | for _, tool := range tools { 109 | names = append(names, tool.Name()) 110 | } 111 | 112 | calls[i].Function.Result = []byte(fmt.Sprintf("ERROR: `%s` is not a valid tool name. The available tools are: %s", fn.Name, names)) 113 | continue 114 | } 115 | 116 | result, err := f.Call(ctx, fn.Parameters) 117 | if err != nil { 118 | calls[i].Function.Result = []byte(fmt.Sprintf("ERROR: Tool `%s` failed: %v", fn.Name, err)) 119 | continue 120 | } 121 | 122 | calls[i].Function.Result = result 123 | } 124 | return calls, nil 125 | } 126 | -------------------------------------------------------------------------------- /_examples/handlers-threadflow/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | 8 | "github.com/chriscow/minds" 9 | "github.com/chriscow/minds/handlers" 10 | "github.com/chriscow/minds/providers/openai" 11 | ) 12 | 13 | // ThreadFlow manages multiple handlers and their middleware chains. Handlers are executed 14 | // sequentially, with each handler receiving the result from the previous one. Middleware 15 | // is scoped and applied to handlers added within that scope, allowing different middleware 16 | // combinations for different processing paths. 17 | 18 | // Example: 19 | 20 | // validate := NewValidationHandler() 21 | // process := NewProcessingHandler() 22 | // seq := Sequential("main", validate, process) 23 | 24 | // // Create flow and add global middleware 25 | // flow := NewThreadFlow("example") 26 | // flow.Use(NewLogging("audit")) 27 | 28 | // // Add base handlers 29 | // flow.Handle(seq) 30 | 31 | // // Add handlers with scoped middleware 32 | // flow.Group(func(f *ThreadFlow) { 33 | // f.Use(NewRetry("retry", 3)) 34 | // f.Use(NewTimeout("timeout", 5)) 35 | // f.Handle(NewContentProcessor("content")) 36 | // f.Handle(NewValidator("validate")) 37 | // }) 38 | 39 | // result, err := flow.HandleThread(initialContext, nil) 40 | 41 | // if err != nil { 42 | // log.Fatalf("Error in flow: %v", err) 43 | // } 44 | 45 | // fmt.Println("Result:", result.Messages().Last().Content) 46 | 47 | func main() { 48 | // This example demonstrates how to use a ThreadFlow. ThreadFlows are a `top-level` 49 | // construct that manage middleware and handlers. ThreadFlow are used to define the 50 | // processing flow for a conversation. In this example, a ThreadFlow is used to 51 | // manage a code review conversation. The conversation is initiated by a user 52 | // asking for a code review. The code review assistant responds with a snarky 53 | // comment about the user's choice of indentation style. The assistant then uses 54 | // a handler to validate the user's indentation style. The handler uses a provider 55 | // to generate a response. The response is then returned to the user. 56 | 57 | // Although the LLM isn't actually used in this example, it's included to show how 58 | // a provider can be used in a handler. 59 | 60 | ctx := context.Background() 61 | llm, err := openai.NewProvider() 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | 66 | flow := handlers.NewThreadFlow("code_review") 67 | flow.Use(NewSarcasmMiddleware(9001)) 68 | 69 | flow.Group(func(f *handlers.ThreadFlow) { 70 | f.Handle(NewTabsVsSpacesValidator(llm)) 71 | }) 72 | 73 | tc := minds.NewThreadContext(ctx).WithMessages(minds.Message{ 74 | Role: minds.RoleUser, 75 | Content: "Please review my Python code that uses 4 spaces for indentation", 76 | }) 77 | 78 | result, err := flow.HandleThread(tc, nil) 79 | if err != nil { 80 | log.Fatal(err) 81 | } 82 | 83 | for _, msg := range result.Messages() { 84 | fmt.Println(msg.Content) 85 | } 86 | } 87 | 88 | // SarcasmMiddleware adds snark to responses 89 | type SarcasmMiddleware struct { 90 | level int 91 | } 92 | 93 | func NewSarcasmMiddleware(level int) handlers.Middleware { 94 | return &SarcasmMiddleware{level: level} 95 | } 96 | 97 | func (m *SarcasmMiddleware) Wrap(next minds.ThreadHandler) minds.ThreadHandler { 98 | return minds.ThreadHandlerFunc(func(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 99 | tc.SetKeyValue("snark_level", m.level) 100 | result, err := next.HandleThread(tc, nil) 101 | return result, err 102 | }) 103 | } 104 | 105 | // TabsVsSpacesValidator checks indentation 106 | type TabsVsSpacesValidator struct { 107 | llm minds.ContentGenerator 108 | } 109 | 110 | func NewTabsVsSpacesValidator(llm minds.ContentGenerator) minds.ThreadHandler { 111 | return &TabsVsSpacesValidator{llm: llm} 112 | } 113 | 114 | func (v *TabsVsSpacesValidator) HandleThread(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 115 | tc.AppendMessages(minds.Message{ 116 | Role: minds.RoleAssistant, 117 | Content: "Spaces?! *adjusts glasses disapprovingly* We use tabs in this house, young programmer.", 118 | }) 119 | return tc, nil 120 | } 121 | -------------------------------------------------------------------------------- /_examples/function-registry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/chriscow/minds" 10 | "github.com/chriscow/minds/providers/gemini" 11 | "github.com/chriscow/minds/providers/openai" 12 | "github.com/fatih/color" 13 | ) 14 | 15 | const prompt = `Make the room cozy and warm` 16 | 17 | var ( 18 | cyan = color.New(color.FgCyan).SprintFunc() 19 | green = color.New(color.FgGreen).SprintFunc() 20 | purple = color.New(color.FgHiMagenta).SprintFunc() 21 | ) 22 | 23 | // Function calling requires a struct to define the parameters 24 | type LightControlParams struct { 25 | Brightness int `json:"brightness" description:"Light level from 0 to 100"` 26 | ColorTemp string `json:"colorTemp" description:"Color temperature (daylight/cool/warm)"` 27 | } 28 | 29 | func controlLight(_ context.Context, args []byte) ([]byte, error) { 30 | var params LightControlParams 31 | if err := json.Unmarshal(args, ¶ms); err != nil { 32 | return nil, err 33 | } 34 | 35 | result := map[string]any{ 36 | "brightness": params.Brightness, 37 | "colorTemp": params.ColorTemp, 38 | } 39 | 40 | return json.Marshal(result) 41 | } 42 | 43 | func main() { 44 | ctx := context.Background() 45 | 46 | // 47 | // Functions need to be wrapped with metadata 48 | // 49 | lightControl, err := minds.WrapFunction( 50 | "control_light", // Google recommends using snake_case for function names with Gemini 51 | "Set the brightness and color temperature of a room light", 52 | LightControlParams{}, 53 | controlLight, 54 | ) 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | // 60 | // You can optionally create a function registry to manage multiple 61 | // functions and pass it to the provider. This gives you the flexibility to 62 | // use the same functions across different providers or to provide your own 63 | // registry implementation. 64 | // 65 | // The providers basically do this same thing by default if you don't 66 | // provide a registry. 67 | // 68 | registry := minds.NewToolRegistry() 69 | registry.Register(lightControl) 70 | 71 | req := minds.Request{ 72 | Messages: minds.Messages{{Role: minds.RoleUser, Content: prompt}}, 73 | } 74 | 75 | withGemini(ctx, registry, req) 76 | withOpenAI(ctx, registry, req) 77 | withDeepSeek(ctx, registry, req) 78 | } 79 | 80 | func withGemini(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 81 | provider, err := gemini.NewProvider(ctx, gemini.WithToolRegistry(registry)) 82 | if err != nil { 83 | panic(err) 84 | } 85 | defer provider.Close() 86 | 87 | resp, err := provider.GenerateContent(ctx, req) 88 | if err != nil { 89 | 90 | panic(err) 91 | } 92 | 93 | printOutput(cyan("Gemini"), resp) 94 | } 95 | 96 | func withOpenAI(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 97 | provider, err := openai.NewProvider(openai.WithToolRegistry(registry)) 98 | if err != nil { 99 | panic(err) 100 | } 101 | defer provider.Close() 102 | 103 | resp, err := provider.GenerateContent(ctx, req) 104 | if err != nil { 105 | panic(err) 106 | } 107 | 108 | printOutput(green("OpenAI"), resp) 109 | } 110 | 111 | func withDeepSeek(ctx context.Context, registry minds.ToolRegistry, req minds.Request) { 112 | baseURl := "https://api.deepseek.com" 113 | apiKey := os.Getenv("DEEPSEEK_API_KEY") 114 | model := "deepseek-chat" 115 | provider, err := openai.NewProvider( 116 | openai.WithAPIKey(apiKey), 117 | openai.WithModel(model), 118 | openai.WithToolRegistry(registry), 119 | openai.WithBaseURL(baseURl), 120 | ) 121 | if err != nil { 122 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 123 | return 124 | } 125 | defer provider.Close() 126 | 127 | resp, err := provider.GenerateContent(ctx, req) 128 | if err != nil { 129 | fmt.Printf("[%s] error: %v", purple("DeepSeek"), err) 130 | return 131 | } 132 | 133 | printOutput(purple("DeepSeek"), resp) 134 | } 135 | 136 | func printOutput(name string, resp minds.Response) { 137 | for _, call := range resp.ToolCalls() { 138 | fn := call.Function 139 | fmt.Printf("[%s] Called %s with args: %v\n", name, fn.Name, string(fn.Parameters)) 140 | fmt.Printf("[%s] Result: %v\n", name, string(fn.Result)) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | ## Overview 4 | Thank you for your interest in contributing to the "Minds Toolkit" project! By following 5 | this guideline, we hope to ensure that your contributions are made smoothly and 6 | efficiently. The Go Minds project is licensed under the [Apache 2.0 7 | License](https://github.com/chriscow/minds/blob/master/LICENSE), and we 8 | welcome contributions through GitHub pull requests. 9 | 10 | ## Reporting Bugs 11 | If you discover a bug, first check the [GitHub Issues page](https://github.com/chriscow/minds/issues) to see if the issue has already been reported. If you're reporting a new issue, please use the "Bug report" template and provide detailed information about the problem, including steps to reproduce it. 12 | 13 | ## Suggesting Features 14 | If you want to suggest a new feature or improvement, first check the [GitHub Issues page](https://github.com/chriscow/minds/issues) to ensure a similar suggestion hasn't already been made. Use the "Feature request" template to provide a detailed description of your suggestion. 15 | 16 | ## Reporting Vulnerabilities 17 | If you identify a security concern, please use the "Report a security vulnerability" template on the [GitHub Issues page](https://github.com/chriscow/minds/issues) to share the details. This report will only be viewable to repository maintainers. You will be credited if the advisory is published. 18 | 19 | ## Questions for Users 20 | If you have questions, please utilize the [GitHub Discussions page](https://github.com/chriscow/minds/discussions). 21 | 22 | ## Contributing Code 23 | There might already be a similar pull requests submitted! Please search for [pull requests](https://github.com/chriscow/minds/pulls) before creating one. 24 | 25 | ### Requirements for Merging a Pull Request 26 | 27 | The requirements to accept a pull request are as follows: 28 | 29 | - All pull requests should be written in Go according to common conventions, formatted with `goimports`, and free of warnings from tools like `golangci-lint`. 30 | - Include tests and ensure all tests pass. 31 | - Maintain test coverage without any reduction. 32 | - All pull requests require approval from at least one Thoughtnet Minds maintainer. 33 | 34 | **Note:** 35 | The merging method for pull requests in this repository is squash merge. 36 | 37 | ### Creating a Pull Request 38 | - Fork the repository. 39 | - Create a new branch and commit your changes. 40 | - Push that branch to GitHub. 41 | - Start a new Pull Request on GitHub. (Please use the pull request template to provide detailed information.) 42 | 43 | **Note:** 44 | If your changes introduce breaking changes, please prefix your pull request title with "[BREAKING_CHANGES]". 45 | 46 | ### Code Style 47 | In this project, we adhere to the standard coding style of Go. Your code should 48 | maintain consistency with the rest of the codebase. To achieve this, please 49 | format your code using tools like `goimports` and resolve any syntax or style 50 | issues with `golangci-lint`. 51 | 52 | **Run goimports:** 53 | ``` 54 | go install golang.org/x/tools/cmd/goimports@latest 55 | ``` 56 | 57 | ``` 58 | goimports -w . 59 | ``` 60 | 61 | **Run golangci-lint:** 62 | ``` 63 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 64 | ``` 65 | 66 | ``` 67 | golangci-lint run --out-format=github-actions 68 | ``` 69 | 70 | ### Unit Test 71 | Please create or update tests relevant to your changes. Ensure all tests run 72 | successfully to verify that your modifications do not adversely affect other 73 | functionalities. 74 | 75 | **Run test:** 76 | ``` 77 | go test -v ./... 78 | ``` 79 | 80 | ### Integration Test 81 | Integration tests are requested against the production version of the OpenAI 82 | API. These tests will verify that the library is properly coded against the 83 | actual behavior of the API, and will fail upon any incompatible change in the 84 | API. I use the `_examples` as integration tests as well. 85 | 86 | **Notes:** These tests send real network traffic to the OpenAI and Google Gemini API and may reach 87 | rate limits. Temporary network problems may also cause the test to fail. 88 | 89 | **Run integration test:** 90 | ``` 91 | OPENAI_API_KEY=XXX GEMINI_API_KEY=XXX make run-examples 92 | ``` 93 | 94 | --- 95 | 96 | We wholeheartedly welcome your active participation. Let's build an amazing project together! 97 | -------------------------------------------------------------------------------- /handlers/policy.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/chriscow/minds" 9 | ) 10 | 11 | // PolicyResultFunc defines a function to handle the result of a policy validation. 12 | // It takes a context, thread context, and validation result, and returns an error 13 | // if the validation fails or cannot be processed. 14 | type PolicyResultFunc func(ctx context.Context, tc minds.ThreadContext, res PolicyResult) error 15 | 16 | // PolicyResult represents the outcome of a policy validation. 17 | type PolicyResult struct { 18 | Valid bool `json:"valid" description:"Whether the content passes policy validation"` 19 | Reason string `json:"reason" description:"Explanation for the validation result"` 20 | Violation string `json:"violation" description:"Description of the specific violation if any"` 21 | } 22 | 23 | // Policy performs policy validation on thread content using a content generator (LLM). 24 | type Policy struct { 25 | llm minds.ContentGenerator // LLM used for generating validation responses 26 | name string // Name of the policy validator 27 | systemPrompt string // System message used to guide the LLM during validation 28 | resultFn PolicyResultFunc // Optional function to process validation results 29 | } 30 | 31 | // NewPolicy creates a new policy validator handler. 32 | // 33 | // The handler uses an LLM to validate thread content against a given policy. A system prompt 34 | // is used to guide the validation process, and the result is processed by the optional 35 | // result function. If the result function is nil, the handler defaults to checking 36 | // the `Valid` field of the validation result. 37 | // 38 | // Parameters: 39 | // - llm: A content generator for generating validation responses. 40 | // - name: The name of the policy validator. 41 | // - systemPrompt: A prompt describing the policy validation rules. 42 | // - resultFn: (Optional) Function to process validation results. 43 | // 44 | // Returns: 45 | // - A thread handler that validates thread content against a policy. 46 | func NewPolicy(llm minds.ContentGenerator, name, systemPrompt string, resultFn PolicyResultFunc) *Policy { 47 | p := &Policy{ 48 | llm: llm, 49 | name: name, 50 | systemPrompt: systemPrompt, 51 | resultFn: resultFn, 52 | } 53 | 54 | return p 55 | } 56 | 57 | // String returns a string representation of the policy validator. 58 | func (h *Policy) String() string { 59 | return fmt.Sprintf("Policy(%s)", h.name) 60 | } 61 | 62 | // validate implements the core policy validation logic 63 | func (h *Policy) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 64 | ctx := tc.Context() 65 | if ctx.Err() != nil { 66 | return tc, ctx.Err() 67 | } 68 | 69 | schema, err := minds.GenerateSchema(PolicyResult{}) 70 | if err != nil { 71 | return tc, fmt.Errorf("failed to generate schema: %w", err) 72 | } 73 | 74 | systemMsg := minds.Message{ 75 | Role: minds.RoleSystem, 76 | Content: h.systemPrompt, 77 | } 78 | 79 | req := minds.Request{ 80 | Options: minds.RequestOptions{ 81 | ResponseSchema: &minds.ResponseSchema{ 82 | Name: "ValidationResult", 83 | Description: "Result of policy validation check", 84 | Definition: *schema, 85 | }, 86 | }, 87 | Messages: append(minds.Messages{systemMsg}, tc.Messages()...), 88 | } 89 | 90 | resp, err := h.llm.GenerateContent(ctx, req) 91 | if err != nil { 92 | return tc, fmt.Errorf("policy validation failed to generate: %w", err) 93 | } 94 | 95 | text := resp.String() 96 | if text == "" { 97 | return tc, fmt.Errorf("no response from policy validation") 98 | } 99 | 100 | var result PolicyResult 101 | if err := json.Unmarshal([]byte(text), &result); err != nil { 102 | return tc, fmt.Errorf("failed to unmarshal validation result from response (%s): %w", text, err) 103 | } 104 | 105 | if ctx.Err() != nil { 106 | return tc, ctx.Err() 107 | } 108 | 109 | if h.resultFn != nil { 110 | if err := h.resultFn(ctx, tc, result); err != nil { 111 | return tc, err 112 | } 113 | } else if !result.Valid { 114 | return tc, fmt.Errorf("policy validation failed: %s", result.Reason) 115 | } 116 | 117 | if next != nil { 118 | return next.HandleThread(tc, nil) 119 | } 120 | 121 | return tc, nil 122 | } 123 | -------------------------------------------------------------------------------- /handlers/for.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/chriscow/minds" 7 | ) 8 | 9 | // ForConditionFn defines a function type that controls loop continuation based on 10 | // the current thread context and iteration count. 11 | type ForConditionFn func(tc minds.ThreadContext, iteration int) bool 12 | 13 | // For represents a handler that repeats processing based on iterations and conditions. 14 | // It supports both fixed iteration counts and conditional execution through a 15 | // continuation function. 16 | type For struct { 17 | name string 18 | handler minds.ThreadHandler 19 | iterations int 20 | continueFn ForConditionFn 21 | middleware []minds.Middleware 22 | } 23 | 24 | // NewFor creates a handler that repeats processing based on iterations and conditions. 25 | // 26 | // A continuation function can optionally control the loop based on the ThreadContext 27 | // and iteration count. The handler runs either until the iteration count is reached, 28 | // the continuation function returns false, or infinitely if iterations is 0. 29 | // 30 | // Parameters: 31 | // - name: Identifier for this loop handler 32 | // - iterations: Number of iterations (0 for infinite) 33 | // - handler: The handler to repeat 34 | // - fn: Optional function controlling loop continuation 35 | // 36 | // Returns: 37 | // - A handler that implements controlled repetition of processing 38 | // 39 | // Example: 40 | // 41 | // loop := NewFor("validation", 3, validateHandler, func(tc ThreadContext, i int) bool { 42 | // return tc.ShouldContinue() 43 | // }) 44 | func NewFor(name string, iterations int, handler minds.ThreadHandler, fn ForConditionFn) *For { 45 | if handler == nil { 46 | panic(fmt.Sprintf("%s: handler cannot be nil", name)) 47 | } 48 | 49 | return &For{ 50 | name: name, 51 | handler: handler, 52 | iterations: iterations, 53 | continueFn: fn, 54 | middleware: make([]minds.Middleware, 0), 55 | } 56 | } 57 | 58 | // Use adds middleware to the handler 59 | func (f *For) Use(middleware ...minds.Middleware) { 60 | f.middleware = append(f.middleware, middleware...) 61 | } 62 | 63 | // With returns a new handler with the provided middleware 64 | func (f *For) With(middleware ...minds.Middleware) minds.ThreadHandler { 65 | newFor := &For{ 66 | name: f.name, 67 | handler: f.handler, 68 | iterations: f.iterations, 69 | continueFn: f.continueFn, 70 | middleware: append([]minds.Middleware{}, f.middleware...), 71 | } 72 | newFor.Use(middleware...) 73 | return newFor 74 | } 75 | 76 | // HandleThread implements the ThreadHandler interface 77 | func (f *For) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 78 | iter := 0 79 | current := tc 80 | 81 | // Continue until iteration count is reached or condition function returns false 82 | for f.iterations == 0 || iter < f.iterations { 83 | // Check continuation condition if provided 84 | if f.continueFn != nil && !f.continueFn(current, iter) { 85 | break 86 | } 87 | 88 | // Check for context cancellation 89 | if current.Context().Err() != nil { 90 | return current, current.Context().Err() 91 | } 92 | 93 | // Setup iteration context with metadata 94 | iterCtx := current.Clone() 95 | meta := iterCtx.Metadata() 96 | meta["iteration"] = iter 97 | iterCtx = iterCtx.WithMetadata(meta) 98 | 99 | // Create wrapped handler for this iteration 100 | wrappedHandler := f.handler 101 | 102 | // Apply middleware in reverse order for proper nesting 103 | for i := len(f.middleware) - 1; i >= 0; i-- { 104 | wrappedHandler = f.middleware[i].Wrap(wrappedHandler) 105 | } 106 | 107 | // Execute the wrapped handler 108 | var err error 109 | current, err = wrappedHandler.HandleThread(iterCtx, nil) 110 | if err != nil { 111 | return current, fmt.Errorf("%s: iteration %d failed: %w", f.name, iter, err) 112 | } 113 | 114 | iter++ 115 | } 116 | 117 | // If there's a next handler, execute it with the final context 118 | if next != nil { 119 | return next.HandleThread(current, nil) 120 | } 121 | 122 | return current, nil 123 | } 124 | 125 | // String returns a string representation of the For handler 126 | func (f *For) String() string { 127 | iterations := "infinite" 128 | if f.iterations > 0 { 129 | iterations = fmt.Sprintf("%d", f.iterations) 130 | } 131 | return fmt.Sprintf("For(%s, %s iterations)", f.name, iterations) 132 | } 133 | -------------------------------------------------------------------------------- /_examples/data-extraction/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/chriscow/minds" 9 | "github.com/chriscow/minds/handlers" 10 | "github.com/chriscow/minds/providers/openai" 11 | ) 12 | 13 | // Define a struct for structured data extraction 14 | type CustomerInfo struct { 15 | Name string `json:"name"` 16 | Age int `json:"age"` 17 | Email string `json:"email"` 18 | Problem string `json:"problem"` 19 | } 20 | 21 | func main() { 22 | // Create a context 23 | ctx := context.Background() 24 | 25 | // Create an OpenAI provider 26 | llm, err := openai.NewProvider() 27 | if err != nil { 28 | fmt.Printf("Error creating OpenAI provider: %v\n", err) 29 | os.Exit(1) 30 | } 31 | defer llm.Close() 32 | 33 | // Create a thread context with a customer support conversation 34 | tc := minds.NewThreadContext(ctx) 35 | tc.AppendMessages( 36 | minds.Message{Role: minds.RoleUser, Content: "Hi, I'm John Smith. I've been having trouble with my account."}, 37 | minds.Message{Role: minds.RoleAssistant, Content: "Hello John, I'm sorry to hear that. Could you provide more details about the issue?"}, 38 | minds.Message{Role: minds.RoleUser, Content: "I'm 35 years old and I've been trying to log in but it says my password is incorrect. My email is john.smith@example.com."}, 39 | minds.Message{Role: minds.RoleAssistant, Content: "I understand. Let me check your account."}, 40 | ) 41 | 42 | // Example 1: Use FreeformExtractor to extract key-value pairs 43 | fmt.Println("Example 1: Freeform Extraction") 44 | fmt.Println("-------------------------------") 45 | 46 | freeformPrompt := `Extract key information from this customer support conversation. 47 | Look for the customer's name, age, email, and the problem they're experiencing. 48 | Format the response as an array of key-value pairs, where each pair includes a "key" field and a "value" field. 49 | For example: [{"key": "name", "value": "John Smith"}, {"key": "age", "value": "35"}]` 50 | 51 | freeformExtractor := handlers.NewFreeformExtractor("customer-info", llm, freeformPrompt) 52 | 53 | resultTc, err := freeformExtractor.HandleThread(tc, nil) 54 | if err != nil { 55 | fmt.Printf("Error processing with FreeformExtractor: %v\n", err) 56 | os.Exit(1) 57 | } 58 | 59 | fmt.Println("Extracted Metadata:") 60 | for key, value := range resultTc.Metadata() { 61 | fmt.Printf(" %s: %v\n", key, value) 62 | } 63 | 64 | // Example 2: Use StructuredExtractor with a schema 65 | fmt.Println("\nExample 2: Structured Extraction") 66 | fmt.Println("--------------------------------") 67 | 68 | structuredPrompt := `Extract customer information from this support conversation. 69 | Identify the customer's name, age, email address, and the problem they're experiencing. 70 | Format the response according to the provided schema.` 71 | 72 | schema, err := minds.NewResponseSchema("customer_info", "Customer information", CustomerInfo{}) 73 | if err != nil { 74 | fmt.Printf("Error creating schema: %v\n", err) 75 | os.Exit(1) 76 | } 77 | 78 | structuredExtractor := handlers.NewStructuredExtractor("customer-details", llm, structuredPrompt, *schema) 79 | 80 | resultTc2, err := structuredExtractor.HandleThread(tc, nil) 81 | if err != nil { 82 | fmt.Printf("Error processing with StructuredExtractor: %v\n", err) 83 | os.Exit(1) 84 | } 85 | 86 | fmt.Println("Extracted Structured Data:") 87 | customerInfo := resultTc2.Metadata()["customer_info"] 88 | fmt.Printf(" %v\n", customerInfo) 89 | 90 | // Example 3: Chaining extractors with middleware 91 | fmt.Println("\nExample 3: Chaining Extractors") 92 | fmt.Println("------------------------------") 93 | 94 | // Create middleware for logging 95 | loggingMiddleware := minds.MiddlewareFunc(func(next minds.ThreadHandler) minds.ThreadHandler { 96 | return minds.ThreadHandlerFunc(func(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 97 | fmt.Println(" Processing conversation...") 98 | result, err := next.HandleThread(tc, nil) 99 | if err == nil { 100 | fmt.Println(" Extraction complete!") 101 | } 102 | return result, err 103 | }) 104 | }) 105 | 106 | // Apply middleware to structured extractor 107 | chainedExtractor := structuredExtractor.With(loggingMiddleware) 108 | 109 | resultTc3, err := chainedExtractor.HandleThread(tc, nil) 110 | if err != nil { 111 | fmt.Printf("Error in chained extraction: %v\n", err) 112 | os.Exit(1) 113 | } 114 | 115 | fmt.Println("Chained Extraction Result:") 116 | customerInfo = resultTc3.Metadata()["customer_info"] 117 | fmt.Printf(" %v\n", customerInfo) 118 | } 119 | -------------------------------------------------------------------------------- /tools/calculator/calculator_lua.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math" 8 | 9 | lua "github.com/yuin/gopher-lua" 10 | ) 11 | 12 | // Execute runs the calculatorLua tool with the given input. 13 | func withLua(_ context.Context, args []byte) ([]byte, error) { 14 | var params struct{ Input string } 15 | if err := json.Unmarshal(args, ¶ms); err != nil { 16 | return nil, err 17 | } 18 | input := params.Input 19 | 20 | L := lua.NewState() 21 | defer L.Close() 22 | 23 | // Open Lua standard libraries to get access to basic operators 24 | L.OpenLibs() 25 | 26 | // Register math functions 27 | mathLib := map[string]lua.LGFunction{ 28 | "floor": func(L *lua.LState) int { 29 | x := L.CheckNumber(1) 30 | L.Push(lua.LNumber(math.Floor(float64(x)))) 31 | return 1 32 | }, 33 | "ceil": func(L *lua.LState) int { 34 | x := L.CheckNumber(1) 35 | L.Push(lua.LNumber(math.Ceil(float64(x)))) 36 | return 1 37 | }, 38 | "round": func(L *lua.LState) int { 39 | x := L.CheckNumber(1) 40 | L.Push(lua.LNumber(math.Round(float64(x)))) 41 | return 1 42 | }, 43 | "sqrt": func(L *lua.LState) int { 44 | x := L.CheckNumber(1) 45 | if float64(x) < 0 { 46 | L.RaiseError("cannot calculate square root of negative number") 47 | return 0 48 | } 49 | L.Push(lua.LNumber(math.Sqrt(float64(x)))) 50 | return 1 51 | }, 52 | "pow": func(L *lua.LState) int { 53 | x := L.CheckNumber(1) 54 | y := L.CheckNumber(2) 55 | result := math.Pow(float64(x), float64(y)) 56 | if math.IsNaN(result) || math.IsInf(result, 0) { 57 | L.RaiseError("invalid power operation") 58 | return 0 59 | } 60 | L.Push(lua.LNumber(result)) 61 | return 1 62 | }, 63 | "sin": func(L *lua.LState) int { 64 | x := L.CheckNumber(1) 65 | L.Push(lua.LNumber(math.Sin(float64(x)))) 66 | return 1 67 | }, 68 | "cos": func(L *lua.LState) int { 69 | x := L.CheckNumber(1) 70 | L.Push(lua.LNumber(math.Cos(float64(x)))) 71 | return 1 72 | }, 73 | "tan": func(L *lua.LState) int { 74 | x := L.CheckNumber(1) 75 | L.Push(lua.LNumber(math.Tan(float64(x)))) 76 | return 1 77 | }, 78 | "asin": func(L *lua.LState) int { 79 | x := L.CheckNumber(1) 80 | if float64(x) < -1 || float64(x) > 1 { 81 | L.RaiseError("asin: argument out of domain") 82 | return 0 83 | } 84 | L.Push(lua.LNumber(math.Asin(float64(x)))) 85 | return 1 86 | }, 87 | "acos": func(L *lua.LState) int { 88 | x := L.CheckNumber(1) 89 | if float64(x) < -1 || float64(x) > 1 { 90 | L.RaiseError("acos: argument out of domain") 91 | return 0 92 | } 93 | L.Push(lua.LNumber(math.Acos(float64(x)))) 94 | return 1 95 | }, 96 | "atan": func(L *lua.LState) int { 97 | x := L.CheckNumber(1) 98 | L.Push(lua.LNumber(math.Atan(float64(x)))) 99 | return 1 100 | }, 101 | "atan2": func(L *lua.LState) int { 102 | y := L.CheckNumber(1) 103 | x := L.CheckNumber(2) 104 | L.Push(lua.LNumber(math.Atan2(float64(y), float64(x)))) 105 | return 1 106 | }, 107 | "abs": func(L *lua.LState) int { 108 | x := L.CheckNumber(1) 109 | L.Push(lua.LNumber(math.Abs(float64(x)))) 110 | return 1 111 | }, 112 | "exp": func(L *lua.LState) int { 113 | x := L.CheckNumber(1) 114 | L.Push(lua.LNumber(math.Exp(float64(x)))) 115 | return 1 116 | }, 117 | "log": func(L *lua.LState) int { 118 | x := L.CheckNumber(1) 119 | base := L.OptNumber(2, math.E) 120 | result := math.Log(float64(x)) 121 | if base != math.E { 122 | result = result / math.Log(float64(base)) 123 | } 124 | L.Push(lua.LNumber(result)) 125 | return 1 126 | }, 127 | "log10": func(L *lua.LState) int { 128 | x := L.CheckNumber(1) 129 | L.Push(lua.LNumber(math.Log10(float64(x)))) 130 | return 1 131 | }, 132 | } 133 | 134 | // Create math table 135 | mathTab := L.NewTable() 136 | for name, fn := range mathLib { 137 | L.SetField(mathTab, name, L.NewFunction(fn)) 138 | } 139 | 140 | // Add constants 141 | L.SetField(mathTab, "pi", lua.LNumber(math.Pi)) 142 | L.SetField(mathTab, "e", lua.LNumber(math.E)) 143 | 144 | // Register the math library 145 | L.SetGlobal("math", mathTab) 146 | 147 | wrappedInput := fmt.Sprintf("return %s", input) 148 | 149 | if err := L.DoString(wrappedInput); err != nil { 150 | return nil, fmt.Errorf("error from evaluator: %s", err.Error()) 151 | } 152 | 153 | // Get the result from the stack 154 | if L.GetTop() > 0 { 155 | result := L.Get(-1) 156 | L.Pop(1) 157 | return []byte(result.String()), nil 158 | } 159 | 160 | return nil, fmt.Errorf("no result value found in script output") 161 | } 162 | -------------------------------------------------------------------------------- /providers/gemini/handler_test.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/chriscow/minds" 12 | "github.com/google/generative-ai-go/genai" 13 | 14 | pb "cloud.google.com/go/ai/generativelanguage/apiv1beta/generativelanguagepb" 15 | "github.com/matryer/is" 16 | ) 17 | 18 | // BUGBUG: The genai package expects the server to respond with some kind of Protobuf message. 19 | // If you trace it back you will see inside of github.com/googleapis/gax-go/v2/proto_json_stream.go 20 | // that it is expecting a JSON array of objects with opening and closing square braces, 21 | // but this server is responding with a JSON object. This is causing the test to fail. 22 | func newMockResponse(role string, parts ...genai.Part) *pb.GenerateContentResponse { 23 | v := &genai.GenerateContentResponse{ 24 | Candidates: []*genai.Candidate{ 25 | { 26 | Content: &genai.Content{ 27 | Role: role, 28 | Parts: parts, 29 | }, 30 | }, 31 | }, 32 | } 33 | return &pb.GenerateContentResponse{ 34 | Candidates: pvTransformSlice(v.Candidates, candidateToProto), 35 | } 36 | } 37 | 38 | func candidateToProto(v *genai.Candidate) *pb.Candidate { 39 | if v == nil { 40 | return nil 41 | } 42 | return &pb.Candidate{ 43 | Index: pvAddrOrNil(v.Index), 44 | Content: contentToProto(v.Content), 45 | FinishReason: pb.Candidate_FinishReason(v.FinishReason), 46 | // SafetyRatings: pvTransformSlice(v.SafetyRatings, (*SafetyRating).toProto), 47 | // CitationMetadata: v.CitationMetadata.toProto(), 48 | TokenCount: v.TokenCount, 49 | } 50 | } 51 | 52 | func textToPart(p genai.Part) *pb.Part { 53 | return &pb.Part{ 54 | Data: &pb.Part_Text{Text: string(p.(genai.Text))}, 55 | } 56 | } 57 | 58 | func contentToProto(v *genai.Content) *pb.Content { 59 | if v == nil { 60 | return nil 61 | } 62 | return &pb.Content{ 63 | Parts: pvTransformSlice(v.Parts, textToPart), 64 | Role: v.Role, 65 | } 66 | } 67 | 68 | func pvAddrOrNil[T comparable](x T) *T { 69 | var z T 70 | if x == z { 71 | return nil 72 | } 73 | return &x 74 | } 75 | 76 | func pvTransformSlice[From, To any](from []From, f func(From) To) []To { 77 | if from == nil { 78 | return nil 79 | } 80 | to := make([]To, len(from)) 81 | for i, e := range from { 82 | to[i] = f(e) 83 | } 84 | return to 85 | } 86 | 87 | func TestHandleMessage(t *testing.T) { 88 | t.Skip("Skipping test: The genai package expects the server to respond with some kind of Protobuf message.") 89 | t.Run("returns updated thread", func(t *testing.T) { 90 | is := is.New(t) 91 | 92 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 93 | json.NewEncoder(w).Encode(newMockResponse("ai", genai.Text("Hello, world!"))) 94 | })) 95 | defer server.Close() 96 | 97 | var provider minds.ContentGenerator 98 | ctx := context.Background() 99 | provider, err := NewProvider(ctx, WithBaseURL(server.URL), WithAPIKey("test")) 100 | is.NoErr(err) // Provider initialization should not fail 101 | 102 | thread := minds.NewThreadContext(context.Background()). 103 | WithMessages(minds.Message{ 104 | Role: minds.RoleUser, Content: "Hi", 105 | }) 106 | 107 | handler, ok := provider.(minds.ThreadHandler) 108 | is.True(ok) // provider should implement the ThreadHandler interface 109 | 110 | result, err := handler.HandleThread(thread, nil) 111 | is.NoErr(err) // HandleMessage should not return an error 112 | messages := result.Messages() 113 | is.Equal(len(messages), 2) 114 | is.Equal(messages[1].Role, minds.RoleAssistant) 115 | is.Equal(messages[1].Content, "Hello, world!") 116 | }) 117 | 118 | t.Run("returns error on failure", func(t *testing.T) { 119 | is := is.New(t) 120 | ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-1*time.Second)) 121 | 122 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 123 | w.Header().Set("Content-Type", "application/json") 124 | json.NewEncoder(w).Encode(newMockResponse("ai", genai.Text("Hello, world!"))) 125 | })) 126 | defer server.Close() 127 | 128 | handler, err := NewProvider(ctx, WithBaseURL(server.URL)) 129 | is.NoErr(err) // Provider initialization should not fail 130 | 131 | thread := minds.NewThreadContext(ctx). 132 | WithMessages(minds.Message{ 133 | Role: minds.RoleUser, Content: "Hi", 134 | }) 135 | 136 | _, err = handler.HandleThread(thread, nil) 137 | is.True(err != nil) // HandleMessage should return an error 138 | is.Equal(err.Error(), context.DeadlineExceeded.Error()) 139 | cancel() 140 | }) 141 | } 142 | -------------------------------------------------------------------------------- /_examples/handlers-must/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/chriscow/minds/handlers" 9 | "github.com/chriscow/minds/providers/gemini" 10 | "github.com/fatih/color" 11 | ) 12 | 13 | // The example demonstrates the must handler. The `Must` handler executes all 14 | // handlers in parallel. It ensures that all handlers succeed. If any handler 15 | // fails, the others are canceled, and the first error is returned. 16 | // 17 | // This is useful for policy enforcement where multiple validators must pass 18 | // before the next handler is executed. 19 | func main() { 20 | red := color.New(color.FgRed).SprintFunc() 21 | green := color.New(color.FgGreen).SprintFunc() 22 | 23 | ctx := context.Background() 24 | llm, err := gemini.NewProvider(ctx) 25 | if err != nil { 26 | fmt.Printf("%s: %v", red("error"), err) 27 | } 28 | 29 | // This sets up a pipeline that validates messages for dad jokes, coffee 30 | // obsession, and unnecessary jargon in parallel using the `must` handler. 31 | // If any of the validators fail, the others are canceled. 32 | validationPipeline := validationPipeline(llm) 33 | 34 | // 35 | // Some example message threads to test the validation pipeline 36 | // 37 | jargon := minds.NewThreadContext(context.Background()).WithMessages(minds.Message{ 38 | Role: minds.RoleUser, 39 | Content: `Leveraging our synergistic capabilities, we aim to 40 | proactively optimize cross-functional alignment and drive 41 | scalable value-add solutions for our stakeholders. By 42 | implementing a paradigm-shifting approach to our core 43 | competencies, we can seamlessly catalyze transformative 44 | outcomes. This ensures a robust framework for sustained 45 | competitive differentiation in a dynamic market landscape.`, 46 | }) 47 | 48 | dad := minds.NewThreadContext(context.Background()).WithMessages(minds.Message{ 49 | Role: minds.RoleUser, 50 | Content: "Hi hungry, I'm dad", 51 | }) 52 | 53 | coffee := minds.NewThreadContext(context.Background()).WithMessages(minds.Message{ 54 | Role: minds.RoleUser, 55 | Content: "Why didn't the coffee file a police report? Because it got mugged! " + 56 | "Speaking of which, time for cup number 6!", 57 | }) 58 | 59 | // Final handler (end of the pipeline) 60 | finalHandler := minds.ThreadHandlerFunc(func(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 61 | fmt.Println("[finalHandler]: " + tc.Messages().Last().Content) 62 | return tc, nil 63 | }) 64 | 65 | // Test the validation pipeline for jargon 66 | if _, err := validationPipeline.HandleThread(jargon, finalHandler); err != nil { 67 | fmt.Printf("[%s] %s: %v\n", green("PASS"), red("Validation failed"), err) 68 | } 69 | 70 | // Test the validation pipeline for dad jokes 71 | if _, err := validationPipeline.HandleThread(dad, finalHandler); err != nil { 72 | fmt.Printf("[%s] %s: %v\n", green("PASS"), red("Validation failed"), err) 73 | } 74 | 75 | // Test the validation pipeline for coffee jokes 76 | if _, err := validationPipeline.HandleThread(coffee, finalHandler); err != nil { 77 | fmt.Printf("[%s] %s: %v\n", green("PASS"), red("Validation failed"), err) 78 | } 79 | } 80 | 81 | func validationPipeline(llm minds.ContentGenerator) minds.ThreadHandler { 82 | // Create policy validators with humorous but detectable rules 83 | validators := []minds.ThreadHandler{ 84 | handlers.NewPolicy( 85 | llm, 86 | "detects_dad_jokes", 87 | `Monitor conversation for classic dad joke patterns like: 88 | - "Hi hungry, I'm dad" 89 | - Puns that make people groan 90 | - Questions with obvious punchlines 91 | Flag if more than 2 dad jokes appear in a 5-message window. 92 | Explain why they are definitely dad jokes.`, 93 | nil, 94 | ), 95 | handlers.NewPolicy( 96 | llm, 97 | "detects_coffee_obsession", 98 | `Analyze messages for signs of extreme coffee dependence: 99 | - Mentions of drinking > 5 cups per day 100 | - Using coffee-based time measurements 101 | - Personifying coffee machines 102 | - Expressing emotional attachment to coffee 103 | Provide concerned feedback about caffeine intake.`, 104 | nil, 105 | ), 106 | handlers.NewPolicy( 107 | llm, 108 | "detects_unnecessary_jargon", 109 | `Monitor for excessive business speak like: 110 | - "Leverage synergies" 111 | - "Circle back" 112 | - "Touch base" 113 | - "Move the needle" 114 | - Using "utilize" instead of "use" 115 | Suggest simpler alternatives in a disappointed tone.`, 116 | nil, 117 | ), 118 | } 119 | 120 | return handlers.NewMust("validators-must-succeed", nil, validators...) 121 | } 122 | -------------------------------------------------------------------------------- /handlers/structured_extractor.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | ) 9 | 10 | // StructuredExtractor is a handler that extracts structured data from conversation messages 11 | // using an LLM and stores it in the ThreadContext metadata. 12 | // It uses the provided prompt, ResponseSchema, and ContentGenerator to analyze the conversation. 13 | type StructuredExtractor struct { 14 | name string 15 | generator minds.ContentGenerator 16 | prompt string 17 | schema minds.ResponseSchema 18 | middleware []minds.Middleware 19 | } 20 | 21 | // NewStructuredExtractor creates a new StructuredExtractor handler. 22 | // The name parameter is used for debugging and logging. 23 | // The generator is used to analyze messages with the given prompt and schema. 24 | // The prompt should instruct the LLM to extract structured data from the conversation. 25 | // The schema defines the structure of the data to extract. 26 | func NewStructuredExtractor(name string, generator minds.ContentGenerator, prompt string, schema minds.ResponseSchema) *StructuredExtractor { 27 | return &StructuredExtractor{ 28 | name: name, 29 | generator: generator, 30 | prompt: prompt, 31 | schema: schema, 32 | middleware: []minds.Middleware{}, 33 | } 34 | } 35 | 36 | // Use applies middleware to the StructuredExtractor handler. 37 | func (s *StructuredExtractor) Use(middleware ...minds.Middleware) { 38 | s.middleware = append(s.middleware, middleware...) 39 | } 40 | 41 | // With returns a new StructuredExtractor with additional middleware, preserving existing state. 42 | func (s *StructuredExtractor) With(middleware ...minds.Middleware) *StructuredExtractor { 43 | newExtractor := &StructuredExtractor{ 44 | name: s.name, 45 | generator: s.generator, 46 | prompt: s.prompt, 47 | schema: s.schema, 48 | middleware: append([]minds.Middleware{}, s.middleware...), 49 | } 50 | newExtractor.Use(middleware...) 51 | return newExtractor 52 | } 53 | 54 | // HandleThread processes the thread context by extracting structured data from messages. 55 | func (s *StructuredExtractor) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 56 | // If there are middleware registered, apply them first and let them handle the processing 57 | if len(s.middleware) > 0 { 58 | // Create a handler that executes the actual logic 59 | handler := minds.ThreadHandlerFunc(func(ctx minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 60 | return s.extractData(ctx, next) 61 | }) 62 | 63 | // Apply middleware in reverse order 64 | var wrappedHandler minds.ThreadHandler = handler 65 | for i := len(s.middleware) - 1; i >= 0; i-- { 66 | wrappedHandler = s.middleware[i].Wrap(wrappedHandler) 67 | } 68 | 69 | // Execute the wrapped handler 70 | return wrappedHandler.HandleThread(tc, next) 71 | } 72 | 73 | // Otherwise, directly process the extraction 74 | return s.extractData(tc, next) 75 | } 76 | 77 | // extractData performs the actual data extraction logic 78 | func (s *StructuredExtractor) extractData(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 79 | // Create a message that combines the instruction prompt with the conversation context 80 | messages := minds.Messages{ 81 | {Role: minds.RoleSystem, Content: s.prompt}, 82 | } 83 | 84 | // Add messages from the conversation as context 85 | for _, msg := range tc.Messages() { 86 | messages = append(messages, minds.Message{ 87 | Role: minds.RoleUser, 88 | Content: fmt.Sprintf("%s: %s", msg.Role, msg.Content), 89 | }) 90 | } 91 | 92 | // Create and send the request to the LLM 93 | req := minds.NewRequest(messages, minds.WithResponseSchema(s.schema)) 94 | resp, err := s.generator.GenerateContent(tc.Context(), req) 95 | if err != nil { 96 | return tc, fmt.Errorf("%s: error generating content: %w", s.name, err) 97 | } 98 | 99 | // Use the schema name as the key in metadata 100 | newTc := tc.Clone() 101 | 102 | // Parse the response as a generic JSON structure 103 | var data any 104 | if err := json.Unmarshal([]byte(resp.String()), &data); err != nil { 105 | return tc, fmt.Errorf("%s: error parsing structured data: %w", s.name, err) 106 | } 107 | 108 | // Store the structured data in metadata using the schema name as the key 109 | newTc.SetKeyValue(s.schema.Name, data) 110 | 111 | // Process next handler if provided 112 | if next != nil { 113 | return next.HandleThread(newTc, nil) 114 | } 115 | 116 | return newTc, nil 117 | } 118 | 119 | // String returns a string representation of the StructuredExtractor handler. 120 | func (s *StructuredExtractor) String() string { 121 | return fmt.Sprintf("StructuredExtractor(%s)", s.name) 122 | } 123 | -------------------------------------------------------------------------------- /handlers/first.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | 8 | "github.com/chriscow/minds" 9 | ) 10 | 11 | // First represents a handler that executes multiple handlers in parallel and returns 12 | // on first success. Each handler runs in its own goroutine, and execution of remaining 13 | // handlers is canceled once a successful result is obtained. 14 | type First struct { 15 | name string 16 | handlers []minds.ThreadHandler 17 | middleware []minds.Middleware 18 | } 19 | 20 | // NewFirst creates a handler that runs multiple handlers in parallel and returns on 21 | // first success. If all handlers fail, an error containing all handler errors is 22 | // returned. If no handlers are provided, the thread context is passed to the next 23 | // handler unmodified. 24 | // 25 | // Parameters: 26 | // - name: Identifier for this parallel handler group 27 | // - handlers: Variadic list of handlers to execute in parallel 28 | // 29 | // Returns: 30 | // - A First handler configured with the provided handlers 31 | // 32 | // Example: 33 | // 34 | // first := handlers.NewFirst("validation", 35 | // validateA, 36 | // validateB, 37 | // validateC, 38 | // ) 39 | func NewFirst(name string, handlers ...minds.ThreadHandler) *First { 40 | return &First{ 41 | name: name, 42 | handlers: handlers, 43 | } 44 | } 45 | 46 | // Use applies middleware to the First handler, wrapping its child handlers. 47 | func (f *First) Use(middleware ...minds.Middleware) { 48 | f.middleware = append(f.middleware, middleware...) 49 | } 50 | 51 | // With returns a new First handler with the provided middleware applied. 52 | func (f *First) With(middleware ...minds.Middleware) minds.ThreadHandler { 53 | newFirst := &First{ 54 | name: f.name, 55 | handlers: append([]minds.ThreadHandler{}, f.handlers...), 56 | } 57 | newFirst.Use(middleware...) 58 | return newFirst 59 | } 60 | 61 | // HandleThread executes the First handler by running child handlers in parallel 62 | // and returning on first success. 63 | func (f *First) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 64 | if len(f.handlers) == 0 { 65 | if next != nil { 66 | return next.HandleThread(tc, nil) 67 | } 68 | return tc, nil 69 | } 70 | 71 | // Create a cancellable context for parallel execution 72 | ctx, cancel := context.WithCancel(tc.Context()) 73 | defer cancel() 74 | 75 | var wg sync.WaitGroup 76 | resultChan := make(chan struct { 77 | tc minds.ThreadContext 78 | err error 79 | }, len(f.handlers)) 80 | 81 | // Execute each handler in parallel 82 | for i, h := range f.handlers { 83 | wg.Add(1) 84 | go func(handler minds.ThreadHandler, idx int) { 85 | defer wg.Done() 86 | 87 | // Check for cancellation before starting 88 | if ctx.Err() != nil { 89 | return 90 | } 91 | 92 | // Clone the context to prevent modifications from affecting other handlers 93 | handlerCtx := tc.Clone().WithContext(ctx) 94 | 95 | // Add handler metadata for middleware context 96 | meta := handlerCtx.Metadata() 97 | meta["handler_name"] = fmt.Sprintf("h%d", idx+1) 98 | handlerCtx = handlerCtx.WithMetadata(meta) 99 | 100 | // Apply middleware in reverse order 101 | wrappedHandler := handler 102 | for i := len(f.middleware) - 1; i >= 0; i-- { 103 | wrappedHandler = f.middleware[i].Wrap(wrappedHandler) 104 | } 105 | 106 | // Execute the wrapped handler 107 | result, err := wrappedHandler.HandleThread(handlerCtx, nil) 108 | 109 | // Send result if not canceled 110 | select { 111 | case resultChan <- struct { 112 | tc minds.ThreadContext 113 | err error 114 | }{result, err}: 115 | if err == nil { 116 | cancel() // Cancel other handlers on success 117 | } 118 | case <-ctx.Done(): 119 | } 120 | }(h, i) 121 | } 122 | 123 | // Close result channel when all handlers complete 124 | go func() { 125 | wg.Wait() 126 | close(resultChan) 127 | }() 128 | 129 | // Collect errors and watch for success 130 | var errors []error 131 | for result := range resultChan { 132 | if result.err == nil { 133 | if next != nil { 134 | return next.HandleThread(result.tc, nil) 135 | } 136 | return result.tc, nil 137 | } 138 | errors = append(errors, fmt.Errorf("%w", result.err)) 139 | } 140 | 141 | // Handle context cancellation 142 | if ctx.Err() != nil { 143 | return tc, ctx.Err() 144 | } 145 | 146 | // Return combined errors if all handlers failed 147 | if len(errors) > 0 { 148 | return tc, fmt.Errorf("%s: all handlers failed: %v", f.name, errors) 149 | } 150 | 151 | return tc, nil 152 | } 153 | 154 | // String returns a string representation of the First handler. 155 | func (f *First) String() string { 156 | return fmt.Sprintf("First(%s)", f.name) 157 | } 158 | -------------------------------------------------------------------------------- /tools/prompt.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "html/template" 8 | "os" 9 | "strings" 10 | 11 | "gopkg.in/yaml.v2" 12 | ) 13 | 14 | type PromptHeader struct { 15 | Name string `yaml:"name,omitempty"` 16 | Version string `yaml:"version,omitempty"` 17 | Format string `yaml:"format,omitempty"` 18 | SHA256 string `yaml:"sha256,omitempty"` 19 | Extra map[string]any `yaml:",inline"` 20 | } 21 | 22 | type Prompt struct { 23 | Header PromptHeader 24 | Template *template.Template 25 | RawContent string 26 | } 27 | 28 | func (p Prompt) Execute(data any) (string, error) { 29 | var result strings.Builder 30 | if err := p.Template.Execute(&result, data); err != nil { 31 | return "", err 32 | } 33 | return result.String(), nil 34 | } 35 | 36 | func GeneratePrompt(content []byte, data any) (string, error) { 37 | tmpl, err := CreatePromptTemplate(content) 38 | if err != nil { 39 | return "", fmt.Errorf("failed to create template: %w", err) 40 | } 41 | 42 | return tmpl.Execute(data) 43 | } 44 | 45 | func validateTemplateContent(content string) error { 46 | // Skip validation for code blocks that might contain valid single braces 47 | // Extract content outside of code blocks (indicated by ```...```) 48 | contentToCheck := content 49 | codeBlocks := strings.Split(content, "```") 50 | 51 | // If there are code blocks, we only want to check the non-code parts 52 | if len(codeBlocks) > 1 { 53 | // Rebuild the content without code blocks 54 | contentToCheck = "" 55 | for i, block := range codeBlocks { 56 | // Even indices are non-code blocks 57 | if i%2 == 0 { 58 | contentToCheck += block 59 | } 60 | } 61 | } 62 | 63 | // Check for unmatched single curly braces in non-code content 64 | doubleBraceCount := strings.Count(contentToCheck, "{{") 65 | doubleCloseBraceCount := strings.Count(contentToCheck, "}}") 66 | 67 | // Count total occurrences of { and } 68 | totalLeftBrace := strings.Count(contentToCheck, "{") 69 | totalRightBrace := strings.Count(contentToCheck, "}") 70 | 71 | // If there are more single braces than double braces, there must be a single brace 72 | if totalLeftBrace > doubleBraceCount*2 { 73 | return fmt.Errorf("template contains single left curly brace '{', use '{{' instead") 74 | } 75 | 76 | if totalRightBrace > doubleCloseBraceCount*2 { 77 | return fmt.Errorf("template contains single right curly brace '}', use '}}' instead") 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func CreatePromptTemplate(content []byte) (Prompt, error) { 84 | var prompt Prompt 85 | 86 | header, body, err := extractYAMLHeader(string(content)) 87 | if err != nil { 88 | return prompt, err 89 | } 90 | 91 | // Validate template content for single curly braces 92 | if err := validateTemplateContent(body); err != nil { 93 | return prompt, err 94 | } 95 | 96 | sha, err := sHA256Hash([]byte(body)) 97 | if err != nil { 98 | return prompt, err 99 | } 100 | if header.SHA256 != "" && header.SHA256 != sha { 101 | return prompt, fmt.Errorf("SHA256 mismatch. Bump the version and update the SHA256") 102 | } else if header.SHA256 == "" { 103 | header.SHA256 = sha 104 | // if err := SavePromptTemplate(filepath, header, body); err != nil { 105 | // return prompt, fmt.Errorf("failed to save prompt template while updating hash: %w", err) 106 | // } 107 | } 108 | prompt.Header = header 109 | prompt.RawContent = body 110 | 111 | tmpl, err := template.New(header.Name).Parse(body) 112 | if err != nil { 113 | return prompt, err 114 | } 115 | prompt.Template = tmpl 116 | 117 | return prompt, nil 118 | } 119 | 120 | func SavePromptTemplate(filePath string, header PromptHeader, body string) error { 121 | file, err := os.Create(filePath) 122 | if err != nil { 123 | return err 124 | } 125 | defer file.Close() 126 | 127 | if _, err := file.WriteString("---\n"); err != nil { 128 | return err 129 | } 130 | meta, err := yaml.Marshal(header) 131 | if err != nil { 132 | return err 133 | } 134 | if _, err := file.Write(meta); err != nil { 135 | return err 136 | } 137 | if _, err := file.WriteString("\n---\n"); err != nil { 138 | return err 139 | } 140 | if _, err := file.WriteString(body); err != nil { 141 | return err 142 | } 143 | return nil 144 | } 145 | 146 | func extractYAMLHeader(templateStr string) (PromptHeader, string, error) { 147 | const delimiter = "---" 148 | var header PromptHeader 149 | parts := strings.Split(templateStr, delimiter) 150 | if len(parts) < 3 { 151 | return header, templateStr, nil 152 | } 153 | 154 | meta := strings.TrimSpace(parts[1]) 155 | if strings.HasPrefix(meta, "-") { 156 | // the user put too many dashes or it was not the right format 157 | return header, templateStr, fmt.Errorf("invalid header format") 158 | } 159 | body := strings.Join(parts[2:], delimiter) 160 | 161 | if err := yaml.Unmarshal([]byte(meta), &header); err != nil { 162 | return header, templateStr, err 163 | } 164 | 165 | return header, strings.TrimSpace(body), nil 166 | } 167 | 168 | func sHA256Hash(data []byte) (string, error) { 169 | h := sha256.New() 170 | _, err := h.Write(data) 171 | if err != nil { 172 | return "", fmt.Errorf("write data: %w", err) 173 | } 174 | 175 | return hex.EncodeToString(h.Sum(nil)), nil 176 | } 177 | -------------------------------------------------------------------------------- /tools/serpapi/serpapi.go: -------------------------------------------------------------------------------- 1 | package serpapi 2 | 3 | // This code was substantially borrowed with appreciation from 4 | // github.com/tmc/langchaingo/tools/serpapi 5 | // and modified to fit the needs of the project. 6 | 7 | import ( 8 | "bytes" 9 | "context" 10 | "encoding/json" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "net/http" 15 | "net/url" 16 | "os" 17 | "strings" 18 | 19 | "github.com/chriscow/minds" 20 | ) 21 | 22 | var ( 23 | ErrMissingToken = errors.New("missing the SerpAPI API key, set it in the SERPAPI_API_KEY environment variable") 24 | ErrNoGoodResult = errors.New("no good search results found") 25 | ErrAPIError = errors.New("error from SerpAPI") 26 | ) 27 | 28 | func New() (minds.Tool, error) { 29 | apiKey := os.Getenv("SERPAPI_API_KEY") 30 | if apiKey == "" { 31 | return nil, ErrMissingToken 32 | } 33 | 34 | return minds.WrapFunction( 35 | "google_search", 36 | `Performs a Google Search. Useful for when you need to answer questions 37 | about current events. Always one of the first options when you need to 38 | find information on internet. Input should be a search query.`, 39 | struct { 40 | Input string `json:"input" description:"A detailed search query."` 41 | }{}, 42 | serpSearch, 43 | ) 44 | } 45 | 46 | func serpSearch(ctx context.Context, args []byte) ([]byte, error) { 47 | var params struct { 48 | Input string `json:"input"` 49 | } 50 | if err := json.Unmarshal(args, ¶ms); err != nil { 51 | return nil, err 52 | } 53 | 54 | result, err := query(ctx, params.Input) 55 | if err != nil { 56 | if errors.Is(err, ErrNoGoodResult) { 57 | return []byte("No relevant Google Search results were found"), nil 58 | } 59 | 60 | return []byte(""), err 61 | } 62 | 63 | return []byte(strings.Join(strings.Fields(result), " ")), nil 64 | } 65 | 66 | func query(ctx context.Context, query string) (string, error) { 67 | const _url = "https://serpapi.com/search" 68 | apiKey := os.Getenv("SERPAPI_API_KEY") 69 | if apiKey == "" { 70 | return "", ErrMissingToken 71 | } 72 | 73 | params := make(url.Values) 74 | query = strings.ReplaceAll(query, " ", "+") 75 | params.Add("q", query) 76 | params.Add("google_domain", "google.com") 77 | params.Add("gl", "us") 78 | params.Add("hl", "en") 79 | params.Add("api_key", apiKey) 80 | 81 | reqURL := fmt.Sprintf("%s?%s", _url, params.Encode()) 82 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) 83 | if err != nil { 84 | return "", fmt.Errorf("creating request in serpapi: %w", err) 85 | } 86 | 87 | res, err := http.DefaultClient.Do(req) 88 | if err != nil { 89 | return "", fmt.Errorf("doing response in serpapi: %w", err) 90 | } 91 | defer res.Body.Close() 92 | 93 | buf := new(bytes.Buffer) 94 | _, err = io.Copy(buf, res.Body) 95 | if err != nil { 96 | return "", fmt.Errorf("coping data in serpapi: %w", err) 97 | } 98 | 99 | var result map[string]any 100 | err = json.Unmarshal(buf.Bytes(), &result) 101 | if err != nil { 102 | return "", fmt.Errorf("unmarshal data in serpapi: %w", err) 103 | } 104 | 105 | return processResponse(result) 106 | } 107 | 108 | func processResponse(res map[string]any) (string, error) { 109 | if errorValue, ok := res["error"]; ok { 110 | return "", fmt.Errorf("%w: %v", ErrAPIError, errorValue) 111 | } 112 | if res := getAnswerBox(res); res != "" { 113 | return res, nil 114 | } 115 | if res := getSportResult(res); res != "" { 116 | return res, nil 117 | } 118 | if res := getKnowledgeGraph(res); res != "" { 119 | return res, nil 120 | } 121 | if res := getOrganicResult(res); res != "" { 122 | return res, nil 123 | } 124 | 125 | return "", ErrNoGoodResult 126 | } 127 | 128 | func getAnswerBox(res map[string]any) string { 129 | answerBox, answerBoxExists := res["answer_box"].(map[string]any) 130 | if answerBoxExists { 131 | if answer, ok := answerBox["answer"].(string); ok { 132 | return answer 133 | } 134 | if snippet, ok := answerBox["snippet"].(string); ok { 135 | return snippet 136 | } 137 | snippetHighlightedWords, ok := answerBox["snippet_highlighted_words"].([]any) 138 | if ok && len(snippetHighlightedWords) > 0 { 139 | return fmt.Sprintf("%v", snippetHighlightedWords[0]) 140 | } 141 | } 142 | 143 | return "" 144 | } 145 | 146 | func getSportResult(res map[string]any) string { 147 | sportsResults, sportsResultsExists := res["sports_results"].(map[string]any) 148 | if sportsResultsExists { 149 | if gameSpotlight, ok := sportsResults["game_spotlight"].(string); ok { 150 | return gameSpotlight 151 | } 152 | } 153 | 154 | return "" 155 | } 156 | 157 | func getKnowledgeGraph(res map[string]any) string { 158 | knowledgeGraph, knowledgeGraphExists := res["knowledge_graph"].(map[string]any) 159 | if knowledgeGraphExists { 160 | if description, ok := knowledgeGraph["description"].(string); ok { 161 | return description 162 | } 163 | } 164 | 165 | return "" 166 | } 167 | 168 | func getOrganicResult(res map[string]any) string { 169 | organicResults, organicResultsExists := res["organic_results"].([]any) 170 | 171 | if organicResultsExists && len(organicResults) > 0 { 172 | organicResult, ok := organicResults[0].(map[string]any) 173 | if ok { 174 | if snippet, ok := organicResult["snippet"].(string); ok { 175 | return snippet 176 | } 177 | } 178 | } 179 | 180 | return "" 181 | } 182 | -------------------------------------------------------------------------------- /middleware/logging.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/chriscow/minds" 9 | ) 10 | 11 | // LoggingOptions defines configuration for logging middleware. 12 | type LoggingOptions struct { 13 | Logger *slog.Logger 14 | LogMessages bool 15 | LogMetadata bool 16 | LogLevels LogLevels 17 | } 18 | 19 | // LogLevels specifies log levels for different events. 20 | type LogLevels struct { 21 | Entry slog.Level 22 | Exit slog.Level 23 | Error slog.Level 24 | } 25 | 26 | // NewLoggingOptions creates default logging configuration. 27 | func NewLoggingOptions() *LoggingOptions { 28 | return &LoggingOptions{ 29 | Logger: slog.Default(), 30 | LogMessages: true, 31 | LogMetadata: true, 32 | LogLevels: LogLevels{ 33 | Entry: slog.LevelInfo, 34 | Exit: slog.LevelInfo, 35 | Error: slog.LevelError, 36 | }, 37 | } 38 | } 39 | 40 | // LoggingOption defines a configuration function for logging middleware. 41 | type LoggingOption func(*LoggingOptions) 42 | 43 | // WithLogger sets a custom logger. 44 | func WithLogger(logger *slog.Logger) LoggingOption { 45 | return func(o *LoggingOptions) { 46 | o.Logger = logger 47 | } 48 | } 49 | 50 | // WithLogMessages configures message logging. 51 | func WithLogMessages(enabled bool) LoggingOption { 52 | return func(o *LoggingOptions) { 53 | o.LogMessages = enabled 54 | } 55 | } 56 | 57 | // WithLogMetadata configures metadata logging. 58 | func WithLogMetadata(enabled bool) LoggingOption { 59 | return func(o *LoggingOptions) { 60 | o.LogMetadata = enabled 61 | } 62 | } 63 | 64 | // WithLogLevels sets custom log levels for different events. 65 | func WithLogLevels(entry, exit, errorLevel slog.Level) LoggingOption { 66 | return func(o *LoggingOptions) { 67 | o.LogLevels.Entry = entry 68 | o.LogLevels.Exit = exit 69 | o.LogLevels.Error = errorLevel 70 | } 71 | } 72 | 73 | // Logging creates a middleware that logs thread execution details. 74 | // 75 | // The middleware provides configurable logging with options to: 76 | // - Use a custom logger 77 | // - Enable/disable message and metadata logging 78 | // - Set custom log levels for different events 79 | // 80 | // Example usage: 81 | // 82 | // flow.Use(Logging("api_handler", 83 | // WithLogger(customLogger), 84 | // WithLogMessages(false), 85 | // WithLogLevels(slog.LevelDebug, slog.LevelInfo, slog.LevelError) 86 | // )) 87 | 88 | // logger provides structured logging for handler execution. 89 | type logger struct { 90 | name string 91 | options *LoggingOptions 92 | } 93 | 94 | // Logging creates a middleware instance for structured logging. 95 | func Logging(name string, opts ...LoggingOption) minds.Middleware { 96 | options := NewLoggingOptions() 97 | for _, opt := range opts { 98 | opt(options) 99 | } 100 | return &logger{name: name, options: options} 101 | } 102 | 103 | // Wrap applies the logging middleware to a handler. 104 | func (l *logger) Wrap(next minds.ThreadHandler) minds.ThreadHandler { 105 | return &loggingHandler{ 106 | name: l.name, 107 | next: next, 108 | options: l.options, 109 | } 110 | } 111 | 112 | // loggingHandler wraps a handler and logs execution details. 113 | type loggingHandler struct { 114 | name string 115 | next minds.ThreadHandler 116 | options *LoggingOptions 117 | } 118 | 119 | // HandleThread logs execution details before and after processing. 120 | func (lh *loggingHandler) HandleThread(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 121 | // Prepare logging attributes 122 | baseAttrs := []any{ 123 | "handler", lh.name, 124 | "thread_id", tc.UUID(), 125 | } 126 | 127 | // Log entry 128 | var name string 129 | if h, ok := lh.next.(fmt.Stringer); ok { 130 | name = ": " + h.String() 131 | } 132 | logEntry(lh.options, tc, baseAttrs, "entering handler"+name, lh.options.LogLevels.Entry) 133 | 134 | // Start timer 135 | start := time.Now() 136 | result, err := lh.next.HandleThread(tc, nil) 137 | duration := time.Since(start) 138 | 139 | // Prepare result attributes 140 | resultAttrs := prepareResultAttributes(baseAttrs, result, lh.options, duration) 141 | 142 | // Log errors 143 | if err != nil { 144 | resultAttrs = append(resultAttrs, "error", err.Error()) 145 | logEntry(lh.options, tc, resultAttrs, "handler error", lh.options.LogLevels.Error) 146 | return result, err 147 | } 148 | 149 | // Log successful exit 150 | logEntry(lh.options, tc, resultAttrs, "exiting handler"+name, lh.options.LogLevels.Exit) 151 | 152 | return result, nil 153 | } 154 | 155 | // logEntry handles logging with configurable options. 156 | func logEntry(options *LoggingOptions, tc minds.ThreadContext, attrs []any, msg string, level slog.Level) { 157 | options.Logger.LogAttrs(tc.Context(), level, msg, slog.Group("thread", attrs...)) 158 | } 159 | 160 | // prepareResultAttributes builds logging attributes for the handler result. 161 | func prepareResultAttributes(baseAttrs []any, result minds.ThreadContext, options *LoggingOptions, duration time.Duration) []any { 162 | attrs := append([]any{}, baseAttrs...) // Copy base attributes 163 | attrs = append(attrs, "duration", duration) 164 | 165 | if options.LogMessages { 166 | attrs = append(attrs, "messages", result.Messages()) 167 | } 168 | if options.LogMetadata { 169 | attrs = append(attrs, "metadata", result.Metadata()) 170 | } 171 | 172 | return attrs 173 | } 174 | -------------------------------------------------------------------------------- /handlers/mocks_test.go: -------------------------------------------------------------------------------- 1 | package handlers_test 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "sync" 8 | "sync/atomic" 9 | "time" 10 | 11 | "github.com/chriscow/minds" 12 | "github.com/chriscow/minds/handlers" 13 | ) 14 | 15 | var ( 16 | errHandlerFailed = errors.New("handler failed") 17 | errMiddlewareFailed = errors.New("middleware failed") 18 | ) 19 | 20 | // Or, if you need more detail, define a struct that implements `error`: 21 | type HandlerError struct { 22 | Reason string 23 | } 24 | 25 | func (e *HandlerError) Error() string { 26 | return e.Reason 27 | } 28 | 29 | type mockHandler struct { 30 | name string 31 | expectedErr error 32 | sleep time.Duration 33 | started int32 34 | completed int32 35 | tcResult minds.ThreadContext 36 | metadata map[string]any 37 | mu sync.Mutex 38 | customExecute func() // Added for flexible execution behavior 39 | } 40 | 41 | func newMockHandler(name string) *mockHandler { 42 | return &mockHandler{ 43 | name: name, 44 | metadata: make(map[string]any), 45 | } 46 | } 47 | 48 | func (m *mockHandler) String() string { 49 | return m.name 50 | } 51 | 52 | func (m *mockHandler) Called() bool { 53 | return m.Completed() > 0 54 | } 55 | 56 | func (m *mockHandler) NotCalled() bool { 57 | return m.Started() == 0 58 | } 59 | 60 | func (m *mockHandler) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 61 | atomic.AddInt32(&m.started, 1) 62 | 63 | if m.tcResult == nil { 64 | m.tcResult = tc.Clone() 65 | m.tcResult.SetKeyValue("handler", m.name) 66 | } 67 | 68 | if m.sleep > 0 { 69 | select { 70 | case <-time.After(m.sleep): 71 | case <-tc.Context().Done(): 72 | return tc, tc.Context().Err() 73 | } 74 | } 75 | 76 | if tc.Context().Err() != nil { 77 | return tc, tc.Context().Err() 78 | } 79 | 80 | if m.expectedErr != nil { 81 | return tc, m.expectedErr 82 | } 83 | 84 | atomic.AddInt32(&m.completed, 1) 85 | return m.tcResult, nil 86 | } 87 | 88 | func (m *mockHandler) Started() int { 89 | return int(atomic.LoadInt32(&m.started)) 90 | } 91 | 92 | func (m *mockHandler) Completed() int { 93 | return int(atomic.LoadInt32(&m.completed)) 94 | } 95 | 96 | // Mock middleware that counts applications 97 | type mockMiddleware struct { 98 | name string 99 | expectedErr error 100 | applied int 101 | executions int 102 | } 103 | 104 | func (m *mockMiddleware) Wrap(next minds.ThreadHandler) minds.ThreadHandler { 105 | m.applied++ 106 | return minds.ThreadHandlerFunc(func(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 107 | m.executions++ 108 | if m.expectedErr != nil { 109 | return tc, m.expectedErr 110 | } 111 | return next.HandleThread(tc, nil) 112 | }) 113 | } 114 | 115 | // Mock middleware handler that implements MiddlewareHandler interface 116 | type mockMiddlewareHandler struct { 117 | *mockHandler 118 | middleware []minds.Middleware 119 | mu sync.Mutex 120 | } 121 | 122 | func newMockMiddlewareHandler(name string) *mockMiddlewareHandler { 123 | return &mockMiddlewareHandler{ 124 | mockHandler: &mockHandler{ 125 | name: name, 126 | metadata: make(map[string]any), 127 | }, 128 | } 129 | } 130 | 131 | func (m *mockMiddlewareHandler) Use(middleware ...minds.Middleware) { 132 | m.mu.Lock() 133 | defer m.mu.Unlock() 134 | m.middleware = append(m.middleware, middleware...) 135 | } 136 | 137 | func (m *mockMiddlewareHandler) With(middleware ...minds.Middleware) minds.ThreadHandler { 138 | m.mu.Lock() 139 | defer m.mu.Unlock() 140 | 141 | newHandler := &mockMiddlewareHandler{ 142 | mockHandler: m.mockHandler, 143 | middleware: append([]minds.Middleware{}, m.middleware...), 144 | } 145 | newHandler.Use(middleware...) 146 | return newHandler 147 | } 148 | 149 | func (m *mockMiddlewareHandler) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 150 | // Apply middleware in reverse order 151 | handler := minds.ThreadHandler(m) 152 | 153 | m.mu.Lock() 154 | middleware := append([]minds.Middleware{}, m.middleware...) 155 | m.mu.Unlock() 156 | 157 | for i := len(middleware) - 1; i >= 0; i-- { 158 | handler = middleware[i].Wrap(handler) 159 | } 160 | 161 | return handler.HandleThread(tc, next) 162 | } 163 | 164 | type mockProvider struct { 165 | response minds.Response 166 | } 167 | 168 | func (m *mockProvider) ModelName() string { 169 | return "mock-model" 170 | } 171 | 172 | func (m *mockProvider) GenerateContent(ctx context.Context, req minds.Request) (minds.Response, error) { 173 | return m.response, nil 174 | } 175 | 176 | func (m *mockProvider) Close() { 177 | // No-op 178 | } 179 | 180 | type mockResponse struct { 181 | Content string 182 | } 183 | 184 | func (m mockResponse) String() string { 185 | return m.Content 186 | } 187 | 188 | func (m mockResponse) ToolCalls() []minds.ToolCall { 189 | return nil 190 | } 191 | 192 | func newMockTextResponse(content string) minds.Response { 193 | return mockResponse{ 194 | Content: content, 195 | } 196 | } 197 | 198 | func newMockBoolResponse(content bool) minds.Response { 199 | resp := handlers.BoolResp{Bool: content} 200 | data, _ := json.Marshal(resp) 201 | 202 | return mockResponse{ 203 | Content: string(data), 204 | } 205 | } 206 | 207 | // mockCondition implements SwitchCondition for testing error cases 208 | type mockCondition struct { 209 | result bool 210 | err error 211 | } 212 | 213 | func (m *mockCondition) Evaluate(tc minds.ThreadContext) (bool, error) { 214 | return m.result, m.err 215 | } 216 | -------------------------------------------------------------------------------- /handlers/freeform_extractor_test.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/chriscow/minds" 8 | "github.com/matryer/is" 9 | ) 10 | 11 | // MockContentGenerator simulates an LLM that returns predetermined responses 12 | type MockContentGenerator struct { 13 | response string 14 | err error 15 | } 16 | 17 | func (m *MockContentGenerator) ModelName() string { 18 | return "mock-model" 19 | } 20 | 21 | func (m *MockContentGenerator) GenerateContent(_ context.Context, _ minds.Request) (minds.Response, error) { 22 | if m.err != nil { 23 | return nil, m.err 24 | } 25 | return &MockResponse{content: m.response}, nil 26 | } 27 | 28 | func (m *MockContentGenerator) Close() {} 29 | 30 | // MockResponse implements the minds.Response interface 31 | type MockResponse struct { 32 | content string 33 | } 34 | 35 | func (m *MockResponse) String() string { 36 | return m.content 37 | } 38 | 39 | func (m *MockResponse) ToolCalls() []minds.ToolCall { 40 | return nil 41 | } 42 | 43 | func TestFreeformExtractor(t *testing.T) { 44 | is := is.New(t) 45 | 46 | // Mock response with key-value pairs in the new format with string values 47 | mockResponse := `{"pairs": [{"key": "name", "value": "John Doe"}, {"key": "age", "value": "30"}, {"key": "email", "value": "john@example.com"}]}` 48 | 49 | // Create a mock content generator 50 | generator := &MockContentGenerator{response: mockResponse} 51 | 52 | // Create a thread context with messages 53 | tc := minds.NewThreadContext(context.Background()) 54 | tc.AppendMessages( 55 | minds.Message{Role: minds.RoleUser, Content: "Hello, my name is John Doe"}, 56 | minds.Message{Role: minds.RoleAssistant, Content: "Hi John, how can I help?"}, 57 | minds.Message{Role: minds.RoleUser, Content: "I'm 30 years old and my email is john@example.com"}, 58 | ) 59 | 60 | // Create a FreeformExtractor handler 61 | extractor := NewFreeformExtractor( 62 | "test-extractor", 63 | generator, 64 | "Extract the following information from the conversation: name, age, and email.", 65 | ) 66 | 67 | // Process the thread 68 | result, err := extractor.HandleThread(tc, nil) 69 | 70 | // Verify no error occurred 71 | is.NoErr(err) 72 | 73 | // Verify the metadata contains the extracted key-value pairs 74 | metadata := result.Metadata() 75 | is.Equal(metadata["name"], "John Doe") 76 | is.Equal(metadata["age"], int64(30)) // Parsed from string to int64 77 | is.Equal(metadata["email"], "john@example.com") 78 | } 79 | 80 | func TestFreeformExtractor_WithNext(t *testing.T) { 81 | is := is.New(t) 82 | 83 | // Mock response with key-value pairs in the new format with string values 84 | mockResponse := `{"pairs": [{"key": "name", "value": "John Doe"}, {"key": "age", "value": "30"}]}` 85 | 86 | // Create a mock content generator 87 | generator := &MockContentGenerator{response: mockResponse} 88 | 89 | // Create a thread context with messages 90 | tc := minds.NewThreadContext(context.Background()) 91 | tc.AppendMessages( 92 | minds.Message{Role: minds.RoleUser, Content: "Hello, my name is John Doe and I'm 30 years old"}, 93 | ) 94 | 95 | // Create a next handler that adds more metadata 96 | nextHandler := minds.ThreadHandlerFunc(func(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 97 | tc.SetKeyValue("processed_by_next", true) 98 | return tc, nil 99 | }) 100 | 101 | // Create a FreeformExtractor handler 102 | extractor := NewFreeformExtractor( 103 | "test-extractor", 104 | generator, 105 | "Extract the following information from the conversation: name and age.", 106 | ) 107 | 108 | // Process the thread 109 | result, err := extractor.HandleThread(tc, nextHandler) 110 | 111 | // Verify no error occurred 112 | is.NoErr(err) 113 | 114 | // Verify the metadata contains both the extracted key-value pairs and the next handler's addition 115 | metadata := result.Metadata() 116 | is.Equal(metadata["name"], "John Doe") 117 | is.Equal(metadata["age"], int64(30)) 118 | is.Equal(metadata["processed_by_next"], true) 119 | } 120 | 121 | func TestFreeformExtractor_WithMiddleware(t *testing.T) { 122 | is := is.New(t) 123 | 124 | // Mock response with key-value pairs in the new format with string values 125 | mockResponse := `{"pairs": [{"key": "name", "value": "John Doe"}]}` 126 | 127 | // Create a mock content generator 128 | generator := &MockContentGenerator{response: mockResponse} 129 | 130 | // Create a thread context with messages 131 | tc := minds.NewThreadContext(context.Background()) 132 | tc.AppendMessages( 133 | minds.Message{Role: minds.RoleUser, Content: "Hello, my name is John Doe"}, 134 | ) 135 | 136 | // Create middleware that adds metadata 137 | middleware := minds.MiddlewareFunc(func(next minds.ThreadHandler) minds.ThreadHandler { 138 | return minds.ThreadHandlerFunc(func(tc minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 139 | tc.SetKeyValue("middleware_applied", true) 140 | return next.HandleThread(tc, nil) 141 | }) 142 | }) 143 | 144 | // Create a FreeformExtractor handler with middleware 145 | extractor := NewFreeformExtractor( 146 | "test-extractor", 147 | generator, 148 | "Extract the name from the conversation.", 149 | ) 150 | extractor.Use(middleware) 151 | 152 | // Process the thread 153 | result, err := extractor.HandleThread(tc, nil) 154 | 155 | // Verify no error occurred 156 | is.NoErr(err) 157 | 158 | // Verify the metadata contains both the extracted key-value pairs and the middleware's addition 159 | metadata := result.Metadata() 160 | is.Equal(metadata["name"], "John Doe") 161 | is.Equal(metadata["middleware_applied"], true) 162 | } 163 | -------------------------------------------------------------------------------- /handlers/freeform_extractor.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/chriscow/minds" 9 | ) 10 | 11 | // FreeformExtractor is a handler that extracts name-value pairs from conversation messages 12 | // using an LLM and stores them in the ThreadContext metadata. 13 | // It uses the provided prompt and ContentGenerator to analyze the conversation. 14 | type FreeformExtractor struct { 15 | name string 16 | generator minds.ContentGenerator 17 | prompt string 18 | middleware []minds.Middleware 19 | } 20 | 21 | // NewFreeformExtractor creates a new FreeformExtractor handler. 22 | // The name parameter is used for debugging and logging. 23 | // The generator is used to analyze messages with the given prompt. 24 | // The prompt should instruct the LLM to extract name-value pairs from the conversation. 25 | func NewFreeformExtractor(name string, generator minds.ContentGenerator, prompt string) *FreeformExtractor { 26 | return &FreeformExtractor{ 27 | name: name, 28 | generator: generator, 29 | prompt: prompt, 30 | middleware: []minds.Middleware{}, 31 | } 32 | } 33 | 34 | // Use applies middleware to the FreeformExtractor handler. 35 | func (f *FreeformExtractor) Use(middleware ...minds.Middleware) { 36 | f.middleware = append(f.middleware, middleware...) 37 | } 38 | 39 | // With returns a new FreeformExtractor with additional middleware, preserving existing state. 40 | func (f *FreeformExtractor) With(middleware ...minds.Middleware) *FreeformExtractor { 41 | newExtractor := &FreeformExtractor{ 42 | name: f.name, 43 | generator: f.generator, 44 | prompt: f.prompt, 45 | middleware: append([]minds.Middleware{}, f.middleware...), 46 | } 47 | newExtractor.Use(middleware...) 48 | return newExtractor 49 | } 50 | 51 | // HandleThread processes the thread context by extracting name-value pairs from messages. 52 | func (f *FreeformExtractor) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 53 | // If there are middleware registered, apply them first and let them handle the processing 54 | if len(f.middleware) > 0 { 55 | // Create a handler that executes the actual logic 56 | handler := minds.ThreadHandlerFunc(func(ctx minds.ThreadContext, _ minds.ThreadHandler) (minds.ThreadContext, error) { 57 | return f.extractData(ctx, next) 58 | }) 59 | 60 | // Apply middleware in reverse order 61 | var wrappedHandler minds.ThreadHandler = handler 62 | for i := len(f.middleware) - 1; i >= 0; i-- { 63 | wrappedHandler = f.middleware[i].Wrap(wrappedHandler) 64 | } 65 | 66 | // Execute the wrapped handler 67 | return wrappedHandler.HandleThread(tc, next) 68 | } 69 | 70 | // Otherwise, directly process the extraction 71 | return f.extractData(tc, next) 72 | } 73 | 74 | // extractData performs the actual data extraction logic 75 | func (f *FreeformExtractor) extractData(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 76 | // Create a message that combines the instruction prompt with the conversation context 77 | messages := minds.Messages{ 78 | {Role: minds.RoleSystem, Content: f.prompt}, 79 | } 80 | 81 | // Add messages from the conversation as context 82 | for _, msg := range tc.Messages() { 83 | messages = append(messages, minds.Message{ 84 | Role: minds.RoleUser, 85 | Content: fmt.Sprintf("%s: %s", msg.Role, msg.Content), 86 | }) 87 | } 88 | 89 | // Define a response schema that will contain an array of key-value pairs 90 | // Since we can't use interface{} in the schema, we'll store all values as strings 91 | // and parse them as needed after extraction 92 | type KeyValuePair struct { 93 | Key string `json:"key"` 94 | Value string `json:"value"` 95 | } 96 | 97 | type ExtractionResult struct { 98 | Pairs []KeyValuePair `json:"pairs"` 99 | } 100 | 101 | schema, err := minds.NewResponseSchema("extraction_result", "Key-value pairs extracted from the conversation", ExtractionResult{}) 102 | if err != nil { 103 | return tc, fmt.Errorf("%s: error creating schema: %w", f.name, err) 104 | } 105 | 106 | // Create and send the request to the LLM 107 | req := minds.NewRequest(messages, minds.WithResponseSchema(*schema)) 108 | resp, err := f.generator.GenerateContent(tc.Context(), req) 109 | if err != nil { 110 | return tc, fmt.Errorf("%s: error generating content: %w", f.name, err) 111 | } 112 | 113 | // Parse the response 114 | var result ExtractionResult 115 | if err := json.Unmarshal([]byte(resp.String()), &result); err != nil { 116 | return tc, fmt.Errorf("%s: error parsing extraction result: %w", f.name, err) 117 | } 118 | 119 | // Add all extracted values to the thread context metadata 120 | // Try to parse numeric and boolean values from string 121 | newTc := tc.Clone() 122 | for _, pair := range result.Pairs { 123 | // Try to convert the string value to appropriate type 124 | value := parseValue(pair.Value) 125 | newTc.SetKeyValue(pair.Key, value) 126 | } 127 | 128 | // Process next handler if provided 129 | if next != nil { 130 | return next.HandleThread(newTc, nil) 131 | } 132 | 133 | return newTc, nil 134 | } 135 | 136 | // parseValue tries to convert a string to a more appropriate type (number, boolean, etc.) 137 | func parseValue(s string) interface{} { 138 | // Try to parse as integer 139 | if i, err := strconv.ParseInt(s, 10, 64); err == nil { 140 | return i 141 | } 142 | 143 | // Try to parse as float 144 | if f, err := strconv.ParseFloat(s, 64); err == nil { 145 | return f 146 | } 147 | 148 | // Try to parse as boolean 149 | if b, err := strconv.ParseBool(s); err == nil { 150 | return b 151 | } 152 | 153 | // Default to string 154 | return s 155 | } 156 | 157 | // String returns a string representation of the FreeformExtractor handler. 158 | func (f *FreeformExtractor) String() string { 159 | return fmt.Sprintf("FreeformExtractor(%s)", f.name) 160 | } 161 | -------------------------------------------------------------------------------- /providers/gemini/provider_test.go: -------------------------------------------------------------------------------- 1 | package gemini 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/chriscow/minds" 11 | "github.com/google/generative-ai-go/genai" 12 | "github.com/matryer/is" 13 | ) 14 | 15 | func TestProvider_GenerateContent(t *testing.T) { 16 | t.Skip("Skipping test: The genai package expects the server to respond with some kind of Protobuf message.") 17 | 18 | is := is.New(t) 19 | 20 | mockResponse := newMockResponse("ai", genai.Text("Hello, world!")) 21 | 22 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Set("Connection", "close") 24 | w.Header().Set("Content-Type", "application/json") 25 | err := json.NewEncoder(w).Encode(mockResponse) 26 | is.NoErr(err) // Encoding should not fail 27 | })) 28 | defer server.Close() 29 | 30 | ctx := context.Background() 31 | provider, err := NewProvider(ctx, WithBaseURL(server.URL)) 32 | is.NoErr(err) // Provider initialization should not fail 33 | 34 | req := minds.Request{ 35 | Messages: minds.Messages{ 36 | {Role: minds.RoleUser, Content: "Hello!"}, 37 | }, 38 | } 39 | 40 | resp, err := provider.GenerateContent(ctx, req) 41 | is.NoErr(err) // GenerateContent should not return an error 42 | is.True(resp != nil) // Response should not be nil 43 | is.Equal(resp.String(), "Hello, world!") // Ensure the mock response matches 44 | } 45 | 46 | func TestProvider_HandleThread(t *testing.T) { 47 | t.Skip("Skipping test: The genai package expects the server to respond with some kind of Protobuf message.") 48 | 49 | is := is.New(t) 50 | 51 | mockResponse := &genai.GenerateContentResponse{ 52 | Candidates: []*genai.Candidate{ 53 | { 54 | Content: &genai.Content{ 55 | Parts: []genai.Part{ 56 | genai.Text("Hello, World!"), 57 | }, 58 | }, 59 | }, 60 | }, 61 | } 62 | 63 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 | w.Header().Set("Content-Type", "application/json") 65 | json.NewEncoder(w).Encode(mockResponse) 66 | })) 67 | defer server.Close() 68 | 69 | var provider minds.ContentGenerator 70 | ctx := context.Background() 71 | provider, err := NewProvider(ctx, WithBaseURL(server.URL)) 72 | is.NoErr(err) // Provider initialization should not fail 73 | 74 | thread := minds.NewThreadContext(ctx). 75 | WithMessages(minds.Message{ 76 | Role: minds.RoleUser, Content: "Hi there!", 77 | }) 78 | 79 | handler, ok := provider.(minds.ThreadHandler) 80 | is.True(ok) // provider should implement the ThreadHandler interface 81 | 82 | result, err := handler.HandleThread(thread, nil) 83 | msgOut := result.Messages() 84 | is.NoErr(err) // HandleThread should not return an error 85 | is.True(len(msgOut) == 2) // There should be two messages: user and assistant 86 | is.Equal(msgOut[1].Role, minds.RoleAssistant) // Ensure the response role is assistant 87 | is.Equal(msgOut[1].Content, "Hello, world!") // Ensure the mock response matches 88 | } 89 | 90 | func TestProvider_GenerateContent_WithToolRegistry(t *testing.T) { 91 | t.Skip("Skipping test: The genai package expects the server to respond with some kind of Protobuf message.") 92 | 93 | is := is.New(t) 94 | 95 | // Define a mock function to register in the tool registry 96 | mockFunction := func(_ context.Context, args []byte) ([]byte, error) { 97 | var params struct { 98 | Value int `json:"value"` 99 | } 100 | if err := json.Unmarshal(args, ¶ms); err != nil { 101 | return nil, err 102 | } 103 | result := map[string]int{"result": params.Value * 2} 104 | return json.Marshal(result) 105 | } 106 | 107 | // Wrap the mock function in a tool 108 | tool, err := minds.WrapFunction( 109 | "mock_function", 110 | "Doubles the input value", 111 | struct { 112 | Value int `json:"value" description:"The value to double"` 113 | }{}, 114 | mockFunction, 115 | ) 116 | is.NoErr(err) // Function wrapping should not fail 117 | 118 | toolRegistry := minds.NewToolRegistry() 119 | err = toolRegistry.Register(tool) 120 | is.NoErr(err) // Tool registration should not fail 121 | 122 | mockResponse := &genai.GenerateContentResponse{ 123 | Candidates: []*genai.Candidate{ 124 | { 125 | Content: &genai.Content{ 126 | Parts: []genai.Part{ 127 | &genai.FunctionCall{ 128 | Name: "mock_function", 129 | Args: map[string]any{"value": 3}, 130 | }, 131 | }, 132 | }, 133 | }, 134 | }, 135 | } 136 | 137 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 138 | w.Header().Set("Content-Type", "application/json") 139 | json.NewEncoder(w).Encode(mockResponse) 140 | })) 141 | defer server.Close() 142 | 143 | ctx := context.Background() 144 | provider, err := NewProvider(ctx, WithBaseURL(server.URL)) 145 | is.NoErr(err) // Provider initialization should not fail 146 | 147 | req := minds.Request{ 148 | Messages: minds.Messages{ 149 | { 150 | Role: minds.RoleUser, 151 | Content: "Call mock_function with value 3", 152 | }, 153 | }, 154 | } 155 | 156 | resp, err := provider.GenerateContent(ctx, req) 157 | is.NoErr(err) // GenerateContent should not return an error 158 | is.True(resp != nil) // Response should not be nil 159 | str := resp.String() 160 | is.Equal(str, "mock_function") // Ensure the mock function was called 161 | 162 | toolCalls := resp.ToolCalls() 163 | is.Equal(len(toolCalls), 1) // Ensure there is exactly one tool call 164 | is.Equal(toolCalls[0].Function.Name, "mock_function") // Ensure the function name matches 165 | 166 | var result map[string]int 167 | is.NoErr(json.Unmarshal(toolCalls[0].Function.Result, &result)) // Should be able to parse the result 168 | is.Equal(result["result"], 6) // Ensure the mock function was called correctly 169 | } 170 | -------------------------------------------------------------------------------- /providers/openai/provider_test.go: -------------------------------------------------------------------------------- 1 | package openai 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/chriscow/minds" 11 | "github.com/matryer/is" 12 | "github.com/sashabaranov/go-openai" 13 | ) 14 | 15 | func newMockTextResponse() openai.ChatCompletionResponse { 16 | return openai.ChatCompletionResponse{ 17 | Choices: []openai.ChatCompletionChoice{{ 18 | Message: openai.ChatCompletionMessage{ 19 | Role: "assistant", 20 | Content: "Hello, world!", 21 | }, 22 | FinishReason: "stop", 23 | }, 24 | }, 25 | } 26 | } 27 | 28 | func newMockFunction() minds.CallableFunc { 29 | return func(_ context.Context, args []byte) ([]byte, error) { 30 | var params struct { 31 | Value int `json:"value"` 32 | } 33 | if err := json.Unmarshal(args, ¶ms); err != nil { 34 | return nil, err 35 | } 36 | result := map[string]int{"result": params.Value * 2} 37 | return json.Marshal(result) 38 | } 39 | } 40 | 41 | func newMockTool() (minds.Tool, error) { 42 | return minds.WrapFunction( 43 | "mock_function", 44 | "Doubles the input value", 45 | struct { 46 | Value int `json:"value" description:"The value to double"` 47 | }{}, 48 | newMockFunction(), 49 | ) 50 | } 51 | 52 | func newMockToolCallResponse() openai.ChatCompletionResponse { 53 | mockResponse := newMockTextResponse() // Ensure this function returns a valid mock response 54 | mockResponse.Choices[0].Message.ToolCalls = []openai.ToolCall{ 55 | { 56 | ID: "12345", 57 | Type: "function", 58 | Function: openai.FunctionCall{ 59 | Name: "mock_function", 60 | Arguments: `{"value": 3}`, 61 | }, 62 | }, 63 | } 64 | mockResponse.Choices[0].FinishReason = openai.FinishReasonToolCalls 65 | return mockResponse 66 | } 67 | 68 | // provider_test.go 69 | func TestProvider_GenerateContent(t *testing.T) { 70 | is := is.New(t) 71 | 72 | // Create a test server that returns mock responses 73 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | w.Header().Set("Content-Type", "application/json") 75 | json.NewEncoder(w).Encode(newMockTextResponse()) 76 | })) 77 | defer server.Close() 78 | 79 | provider, err := NewProvider(WithBaseURL(server.URL)) 80 | is.NoErr(err) // Provider initialization should not fail 81 | 82 | ctx := context.Background() 83 | req := minds.Request{ 84 | Messages: minds.Messages{ 85 | {Role: minds.RoleUser, Content: "Hello!"}, 86 | }, 87 | } 88 | 89 | resp, err := provider.GenerateContent(ctx, req) 90 | is.NoErr(err) // GenerateContent should not return an error 91 | is.True(resp != nil) // Response should not be nil 92 | is.Equal(resp.String(), "Hello, world!") // Ensure the mock response matches 93 | } 94 | 95 | func TestProvider_HandleThread(t *testing.T) { 96 | is := is.New(t) 97 | 98 | // Create a test server that returns mock responses 99 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 100 | w.Header().Set("Content-Type", "application/json") 101 | json.NewEncoder(w).Encode(newMockTextResponse()) 102 | })) 103 | defer server.Close() 104 | 105 | handler, err := NewProvider(WithBaseURL(server.URL)) 106 | is.NoErr(err) // Provider initialization should not fail 107 | 108 | ctx := context.Background() 109 | 110 | thread := minds.NewThreadContext(ctx).WithMessages(minds.Message{ 111 | Role: minds.RoleUser, Content: "Hi there!", 112 | }) 113 | 114 | result, err := handler.HandleThread(thread, nil) 115 | is.NoErr(err) // HandleThread should not return an error 116 | is.True(len(result.Messages()) == 2) // There should be two messages: user and assistant 117 | is.Equal(result.Messages()[1].Role, minds.RoleAssistant) // Ensure the response role is assistant 118 | is.Equal(result.Messages()[1].Content, "Hello, world!") // Ensure the mock response matches 119 | } 120 | 121 | // Define a mock function, register in the tool registry and pass it to the provider. 122 | // Ensure the provider calls the function and returns the result. 123 | func TestProvider_GenerateContent_WithToolRegistry(t *testing.T) { 124 | is := is.New(t) 125 | 126 | // 127 | // Setup 128 | // 129 | 130 | // Define a mock function to register in the tool registry 131 | tool, err := newMockTool() 132 | is.NoErr(err) // Function wrapping should not fail 133 | 134 | toolRegistry := minds.NewToolRegistry() 135 | err = toolRegistry.Register(tool) 136 | is.NoErr(err) // Tool registration should not fail 137 | 138 | mockResponse := newMockToolCallResponse() 139 | 140 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 141 | w.Header().Set("Content-Type", "application/json") 142 | json.NewEncoder(w).Encode(mockResponse) 143 | })) 144 | defer server.Close() 145 | 146 | provider, err := NewProvider(WithBaseURL(server.URL), WithToolRegistry(toolRegistry)) 147 | is.NoErr(err) // Provider initialization should not fail 148 | 149 | req := minds.Request{ 150 | Messages: minds.Messages{ 151 | { 152 | Role: minds.RoleUser, 153 | Content: "Call mock_function with value 3", 154 | }, 155 | }, 156 | } 157 | 158 | // 159 | // Run the test 160 | // 161 | ctx := context.Background() 162 | resp, err := provider.GenerateContent(ctx, req) 163 | is.NoErr(err) // GenerateContent should not return an error 164 | is.True(resp != nil) // Response should not be nil 165 | str := resp.String() 166 | is.Equal(str, "Hello, world!") // Ensure the mock response matches 167 | 168 | toolCalls := resp.ToolCalls() 169 | is.Equal(len(toolCalls), 1) // Ensure there is exactly one tool call 170 | is.Equal(toolCalls[0].Function.Name, "mock_function") // Ensure the function name matches 171 | 172 | var result map[string]int 173 | is.NoErr(json.Unmarshal(toolCalls[0].Function.Result, &result)) // Should be able to parse the result 174 | is.Equal(result["result"], 6) // Ensure the mock function was called correctly 175 | } 176 | -------------------------------------------------------------------------------- /handlers/switch.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/chriscow/minds" 8 | ) 9 | 10 | // SwitchCondition represents a condition that can be evaluated against a thread context. 11 | // Implementations of this interface are used by the Switch handler to determine which 12 | // case should be executed. 13 | type SwitchCondition interface { 14 | // Evaluate examines the thread context and returns true if the condition is met. 15 | // It returns an error if the evaluation fails. 16 | Evaluate(tc minds.ThreadContext) (bool, error) 17 | } 18 | 19 | type ConditionFunc func(tc minds.ThreadContext) (bool, error) 20 | 21 | func (f ConditionFunc) Evaluate(tc minds.ThreadContext) (bool, error) { 22 | return f(tc) 23 | } 24 | 25 | // SwitchCase pairs a condition with a handler. When the condition evaluates to true, 26 | // the corresponding handler is executed. 27 | type SwitchCase struct { 28 | Condition SwitchCondition 29 | Handler minds.ThreadHandler 30 | } 31 | 32 | // Switch executes the first matching case's handler, or the default handler if no cases match. 33 | type Switch struct { 34 | name string 35 | cases []SwitchCase 36 | defaultHandler minds.ThreadHandler 37 | middleware []minds.Middleware 38 | } 39 | 40 | // NewSwitch creates a new Switch handler that executes the first matching case's 41 | // handler. If no cases match, it executes the default handler. The name 42 | // parameter is used for debugging and logging purposes. 43 | // 44 | // The SwitchCase struct pairs a `SwitchCondition` interface with a handler. 45 | // When the condition evaluates to true, the corresponding handler is executed. 46 | // 47 | // Example: 48 | // 49 | // // The MetadataEquals condition checks if the metadata key "type" equals "question" 50 | // metadata := MetadataEquals{Key: "type", Value: "question"} 51 | // questionHandler := SomeQuestionHandler() 52 | // defaultHandler := DefaultHandler() 53 | // 54 | // sw := Switch("type-switch", 55 | // 56 | // defaultHandler, 57 | // SwitchCase{metadata, questionHandler}, 58 | // 59 | // ) 60 | func NewSwitch(name string, defaultHandler minds.ThreadHandler, cases ...SwitchCase) *Switch { 61 | return &Switch{ 62 | name: name, 63 | cases: cases, 64 | defaultHandler: defaultHandler, 65 | } 66 | } 67 | 68 | // Use applies middleware to the Switch handler. 69 | func (s *Switch) Use(middleware ...minds.Middleware) { 70 | s.middleware = append(s.middleware, middleware...) 71 | } 72 | 73 | // With returns a new Switch handler with additional middleware, preserving existing state. 74 | func (s *Switch) With(middleware ...minds.Middleware) *Switch { 75 | newSwitch := &Switch{ 76 | name: s.name, 77 | cases: append([]SwitchCase{}, s.cases...), 78 | defaultHandler: s.defaultHandler, 79 | middleware: append([]minds.Middleware{}, s.middleware...), 80 | } 81 | newSwitch.Use(middleware...) 82 | return newSwitch 83 | } 84 | 85 | // HandleThread processes the thread context, executing the first matching case's handler. 86 | func (s *Switch) HandleThread(tc minds.ThreadContext, next minds.ThreadHandler) (minds.ThreadContext, error) { 87 | for _, c := range s.cases { 88 | matches, err := c.Condition.Evaluate(tc) 89 | if err != nil { 90 | return tc, fmt.Errorf("%s: error evaluating condition: %w", s.name, err) 91 | } 92 | if matches { 93 | return s.executeWithMiddleware(tc, c.Handler, next) 94 | } 95 | } 96 | 97 | // No cases matched, use the default handler 98 | if s.defaultHandler != nil { 99 | return s.executeWithMiddleware(tc, s.defaultHandler, next) 100 | } 101 | 102 | return tc, nil 103 | } 104 | 105 | // executeWithMiddleware applies middleware to a handler before execution. 106 | func (s *Switch) executeWithMiddleware(tc minds.ThreadContext, handler minds.ThreadHandler, next minds.ThreadHandler) (minds.ThreadContext, error) { 107 | wrappedHandler := handler 108 | 109 | // Apply middleware in reverse order 110 | for i := len(s.middleware) - 1; i >= 0; i-- { 111 | wrappedHandler = s.middleware[i].Wrap(wrappedHandler) 112 | } 113 | 114 | return wrappedHandler.HandleThread(tc, next) 115 | } 116 | 117 | // String returns a string representation of the Switch handler. 118 | func (s *Switch) String() string { 119 | return fmt.Sprintf("Switch(%s)", s.name) 120 | } 121 | 122 | // --- Condition Implementations --- 123 | 124 | // MetadataEquals checks if a metadata key equals a specific value. 125 | type MetadataEquals struct { 126 | Key string 127 | Value any 128 | } 129 | 130 | // Evaluate checks if the metadata value for the specified key equals the target value. 131 | func (m MetadataEquals) Evaluate(tc minds.ThreadContext) (bool, error) { 132 | val, exists := tc.Metadata()[m.Key] 133 | if !exists { 134 | return false, nil 135 | } 136 | return val == m.Value, nil 137 | } 138 | 139 | // LLMCondition evaluates a condition using an LLM response. 140 | type LLMCondition struct { 141 | Generator minds.ContentGenerator 142 | Prompt string 143 | } 144 | 145 | // BoolResp represents the expected JSON response format from the LLM. 146 | type BoolResp struct { 147 | Bool bool `json:"bool"` 148 | } 149 | 150 | // Evaluate sends a prompt to the LLM and expects a boolean response. 151 | func (l LLMCondition) Evaluate(tc minds.ThreadContext) (bool, error) { 152 | messages := minds.Messages{{Role: minds.RoleUser, Content: l.Prompt}} 153 | 154 | // Add the current thread's last message as context 155 | if len(tc.Messages()) > 0 { 156 | lastMsg := tc.Messages().Last() 157 | messages = append(messages, minds.Message{ 158 | Role: minds.RoleSystem, 159 | Content: fmt.Sprintf("Previous message: %s", lastMsg.Content), 160 | }) 161 | } 162 | 163 | schema, err := minds.NewResponseSchema("boolean_response", "Requires a boolean response", BoolResp{}) 164 | if err != nil { 165 | return false, fmt.Errorf("error creating response schema: %w", err) 166 | } 167 | 168 | req := minds.NewRequest(messages, minds.WithResponseSchema(*schema)) 169 | resp, err := l.Generator.GenerateContent(tc.Context(), req) 170 | if err != nil { 171 | return false, fmt.Errorf("error generating LLM response: %w", err) 172 | } 173 | 174 | result := BoolResp{} 175 | if err := json.Unmarshal([]byte(resp.String()), &result); err != nil { 176 | return false, fmt.Errorf("error unmarshalling response: %w", err) 177 | } 178 | 179 | return result.Bool, nil 180 | } 181 | --------------------------------------------------------------------------------