From 78ba0e2686acd4428fc6e96060067c716a21fda2 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 09:17:30 +0100 Subject: [PATCH 01/33] First import --- .gitignore | 3 + agent.go | 26 ++++ context.go | 10 ++ error.go | 49 ++++++ go.mod | 15 ++ go.sum | 14 ++ model.go | 7 + opt.go | 6 + pkg/agent/agent.go | 35 +++++ pkg/agent/opt.go | 49 ++++++ pkg/ollama/chat.go | 112 ++++++++++++++ pkg/ollama/chat_test.go | 109 +++++++++++++ pkg/ollama/client.go | 55 +++++++ pkg/ollama/client_test.go | 32 ++++ pkg/ollama/doc.go | 5 + pkg/ollama/embedding.go | 95 ++++++++++++ pkg/ollama/embedding_test.go | 28 ++++ pkg/ollama/message.go | 89 +++++++++++ pkg/ollama/model.go | 239 +++++++++++++++++++++++++++++ pkg/ollama/model_test.go | 73 +++++++++ pkg/ollama/opt.go | 132 ++++++++++++++++ pkg/ollama/testdata/guggenheim.jpg | Bin 0 -> 139053 bytes pkg/ollama/tool.go | 224 +++++++++++++++++++++++++++ pkg/ollama/tool_test.go | 29 ++++ response.go | 29 ++++ tool.go | 96 ++++++++++++ 26 files changed, 1561 insertions(+) create mode 100644 agent.go create mode 100644 context.go create mode 100644 error.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 model.go create mode 100644 opt.go create mode 100644 pkg/agent/agent.go create mode 100644 pkg/agent/opt.go create mode 100644 pkg/ollama/chat.go create mode 100644 pkg/ollama/chat_test.go create mode 100644 pkg/ollama/client.go create mode 100644 pkg/ollama/client_test.go create mode 100644 pkg/ollama/doc.go create mode 100644 pkg/ollama/embedding.go create mode 100644 pkg/ollama/embedding_test.go create mode 100644 pkg/ollama/message.go create mode 100644 pkg/ollama/model.go create mode 100644 pkg/ollama/model_test.go create mode 100644 pkg/ollama/opt.go create mode 100644 pkg/ollama/testdata/guggenheim.jpg create mode 100644 pkg/ollama/tool.go create mode 100644 pkg/ollama/tool_test.go create mode 100644 response.go create mode 100644 tool.go diff --git a/.gitignore b/.gitignore index 6f72f89..b9c3011 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ go.work.sum # env file .env + +# build output +build/ diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..b97ee43 --- /dev/null +++ b/agent.go @@ -0,0 +1,26 @@ +package llm + +import ( + "context" +) + +// An LLM Agent is a client for the LLM service +type Agent interface { + // Return the name of the agent + Name() string + + // Return the models + Models(context.Context) ([]Model, error) + + // Generate a response from a prompt + Generate(context.Context, Model, Context, ...Opt) (*Response, error) + + // Embedding vector generation + Embedding(context.Context, Model, string, ...Opt) ([]float64, error) + + // Create user message context + UserPrompt(string, ...Opt) Context + + // Create the result of calling a tool + ToolResult(any) Context +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..fb479d2 --- /dev/null +++ b/context.go @@ -0,0 +1,10 @@ +package llm + +////////////////////////////////////////////////////////////////// +// TYPES + +// Context is fed to the agent to generate a response. Role can be +// assistant, user, tool, tool_result, ... +type Context interface { + Role() string +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..4bb0c9d --- /dev/null +++ b/error.go @@ -0,0 +1,49 @@ +package llm + +import ( + "fmt" +) + +//////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + ErrSuccess Err = iota + ErrNotFound + ErrBadParameter + ErrNotImplemented + ErrConflict +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Errors +type Err int + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (e Err) Error() string { + switch e { + case ErrSuccess: + return "success" + case ErrNotFound: + return "not found" + case ErrBadParameter: + return "bad parameter" + case ErrNotImplemented: + return "not implemented" + case ErrConflict: + return "conflict" + } + return fmt.Sprintf("error code %d", int(e)) +} + +func (e Err) With(args ...interface{}) error { + return fmt.Errorf("%w: %s", e, fmt.Sprint(args...)) +} + +func (e Err) Withf(format string, args ...interface{}) error { + return fmt.Errorf("%w: %s", e, fmt.Sprintf(format, args...)) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4458b85 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/mutablelogic/go-llm + +go 1.23.5 + +require ( + github.com/mutablelogic/go-client v1.0.9 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/djthorpe/go-errors v1.0.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..974cf49 --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/djthorpe/go-errors v1.0.3 h1:GZeMPkC1mx2vteXLI/gvxZS0Ee9zxzwD1mcYyKU5jD0= +github.com/djthorpe/go-errors v1.0.3/go.mod h1:HtfrZnMd6HsX75Mtbv9Qcnn0BqOrrFArvCaj3RMnZhY= +github.com/mutablelogic/go-client v1.0.9 h1:Eh4sjQOFDldP/L3IizqkcOD3WigZR+u1VaHTUM4ujYw= +github.com/mutablelogic/go-client v1.0.9/go.mod h1:VLyB8j8IBJSK/FXvvqhmq93PRWDKkyLu8R7V2Vudb6A= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/model.go b/model.go new file mode 100644 index 0000000..ffaa169 --- /dev/null +++ b/model.go @@ -0,0 +1,7 @@ +package llm + +// An Model can be used to generate a response +type Model interface { + // Return the name of the model + Name() string +} diff --git a/opt.go b/opt.go new file mode 100644 index 0000000..e821b83 --- /dev/null +++ b/opt.go @@ -0,0 +1,6 @@ +package llm + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Opt func(any) error diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go new file mode 100644 index 0000000..c5854d8 --- /dev/null +++ b/pkg/agent/agent.go @@ -0,0 +1,35 @@ +package agent + +import ( + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type agent struct { + *opt +} + +var _ llm.Agent = (*agent)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Return a new agent, composed of several different models from different providers +func New(opts ...llm.Opt) (*agent, error) { + agent := new(agent) + opt, err := apply(opts...) + if err != nil { + return nil, err + } else { + agent.opt = opt + } + + // Return success + return agent, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go new file mode 100644 index 0000000..de17379 --- /dev/null +++ b/pkg/agent/opt.go @@ -0,0 +1,49 @@ +package agent + +import ( + // Packages + "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" + "github.com/mutablelogic/go-llm/pkg/ollama" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opt struct { + agents map[string]llm.Agent +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func apply(opts ...llm.Opt) (*opt, error) { + o := new(opt) + o.agents = make(map[string]llm.Agent) + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + return o, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// OPTIONS + +// Ollama +func WithOllama(endpoint string, opts ...client.ClientOpt) llm.Opt { + return func(o any) error { + client, err := ollama.New(endpoint, opts...) + if err != nil { + return err + } + name := client.Name() + if _, exists := o.(*opt).agents[name]; exists { + return llm.ErrConflict.Withf("Agent %q already exists", name) + } else { + o.(*opt).agents[name] = client + } + return nil + } +} diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go new file mode 100644 index 0000000..1d1cde1 --- /dev/null +++ b/pkg/ollama/chat.go @@ -0,0 +1,112 @@ +package ollama + +import ( + "context" + "encoding/json" + "time" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Chat Response +type Response struct { + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + Message MessageMeta `json:"message"` + Done bool `json:"done"` + Reason string `json:"done_reason,omitempty"` + Context []*MessageMeta `json:"-"` + Metrics +} + +// Metrics +type Metrics struct { + TotalDuration time.Duration `json:"total_duration,omitempty"` + LoadDuration time.Duration `json:"load_duration,omitempty"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` + PromptEvalDuration time.Duration `json:"prompt_eval_duration,omitempty"` + EvalCount int `json:"eval_count,omitempty"` + EvalDuration time.Duration `json:"eval_duration,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (r Response) String() string { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +type reqChat struct { + Model string `json:"model"` + Messages []*MessageMeta `json:"messages"` + Tools []*Tool `json:"tools,omitempty"` + Format string `json:"format,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` + Stream bool `json:"stream"` + KeepAlive *time.Duration `json:"keep_alive,omitempty"` +} + +func (ollama *Client) Chat(ctx context.Context, name string, prompt llm.Context, opts ...llm.Opt) (*Response, error) { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil, err + } + + // Append the context to the set of messages + messages := make([]*MessageMeta, 0, len(opt.context)+2) + copy(messages, opt.context) + + // Context to append to the request + message, ok := prompt.(*message) + if !ok || message == nil { + return nil, llm.ErrBadParameter.With("incompatible context") + } else if message.Role() != "user" { + return nil, llm.ErrBadParameter.Withf("invalid role, %q", message.Role()) + } else { + messages = append(messages, &message.MessageMeta) + } + + // Request + req, err := client.NewJSONRequest(reqChat{ + Model: name, + Messages: messages, + Tools: opt.tools, + Format: opt.format, + Options: opt.options, + Stream: opt.stream, + KeepAlive: opt.keepalive, + }) + if err != nil { + return nil, err + } + + // Response + var response Response + if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("chat"), client.OptJsonStreamCallback(func(v any) error { + if v, ok := v.(*Response); ok && opt.chatcallback != nil { + opt.chatcallback(v) + } + return nil + })); err != nil { + return nil, err + } + + // Append the response message to the context + response.Context = append(messages, &response.Message) + + // Return success + return &response, nil +} diff --git a/pkg/ollama/chat_test.go b/pkg/ollama/chat_test.go new file mode 100644 index 0000000..088168d --- /dev/null +++ b/pkg/ollama/chat_test.go @@ -0,0 +1,109 @@ +package ollama_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" + assert "github.com/stretchr/testify/assert" +) + +func Test_chat_001(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + // Pull the model + if err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + t.Log(status) + })); err != nil { + t.FailNow() + } + + t.Run("ChatStream", func(t *testing.T) { + assert := assert.New(t) + response, err := client.Chat(context.TODO(), "qwen:0.5b", client.UserPrompt("why is the sky blue?"), ollama.WithChatStream(func(stream *ollama.Response) { + t.Log(stream) + })) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(response) + }) + + t.Run("ChatNoStream", func(t *testing.T) { + assert := assert.New(t) + response, err := client.Chat(context.TODO(), "qwen:0.5b", client.UserPrompt("why is the sky green?")) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(response) + }) +} + +func Test_chat_002(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + // Pull the model + if err := client.PullModel(context.TODO(), "llama3.2:1b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + t.Log(status) + })); err != nil { + t.FailNow() + } + + t.Run("Tools", func(t *testing.T) { + assert := assert.New(t) + response, err := client.Chat(context.TODO(), "llama3.2:1b", + client.UserPrompt("what is the weather in berlin?"), + ollama.WithTool(ollama.MustTool("get_weather", "Return weather conditions in a location", struct { + Location string `help:"Location to get weather for" required:""` + }{})), + ) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(response) + }) +} + +func Test_chat_003(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, false)) + if err != nil { + t.FailNow() + } + + // Delete model + client.DeleteModel(context.TODO(), "llava") + + // Pull the model + if err := client.PullModel(context.TODO(), "llava", ollama.WithPullStatus(func(status *ollama.PullStatus) { + t.Log(status) + })); err != nil { + t.FailNow() + } + + t.Run("Image", func(t *testing.T) { + assert := assert.New(t) + + f, err := os.Open("testdata/guggenheim.jpg") + if !assert.NoError(err) { + t.FailNow() + } + defer f.Close() + + response, err := client.Chat(context.TODO(), "llava", + client.UserPrompt("where was this photo taken?", ollama.WithData(f)), + ) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(response) + }) +} diff --git a/pkg/ollama/client.go b/pkg/ollama/client.go new file mode 100644 index 0000000..2a399d6 --- /dev/null +++ b/pkg/ollama/client.go @@ -0,0 +1,55 @@ +package ollama + +import ( + "context" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Client struct { + *client.Client +} + +// Ensure it satisfies the agent.Agent interface +var _ llm.Agent = (*Client)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + defaultName = "ollama" +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create a new client, with an ollama endpoint, which should be something like +// "http://localhost:11434/api" +func New(endPoint string, opts ...client.ClientOpt) (*Client, error) { + // Create client + client, err := client.New(append(opts, client.OptEndpoint(endPoint))...) + if err != nil { + return nil, err + } + + // Return the client + return &Client{client}, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return the name of the agent +func (*Client) Name() string { + return defaultName +} + +// Generate a response from a prompt +func (ollama *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (*llm.Response, error) { + return nil, llm.ErrNotImplemented +} diff --git a/pkg/ollama/client_test.go b/pkg/ollama/client_test.go new file mode 100644 index 0000000..851b98b --- /dev/null +++ b/pkg/ollama/client_test.go @@ -0,0 +1,32 @@ +package ollama_test + +import ( + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" + assert "github.com/stretchr/testify/assert" +) + +func Test_client_001(t *testing.T) { + assert := assert.New(t) + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ENVIRONMENT + +func GetEndpoint(t *testing.T) string { + key := os.Getenv("OLLAMA_URL") + if key == "" { + t.Skip("OLLAMA_URL not set, skipping tests") + t.SkipNow() + } + return key +} diff --git a/pkg/ollama/doc.go b/pkg/ollama/doc.go new file mode 100644 index 0000000..c652fb9 --- /dev/null +++ b/pkg/ollama/doc.go @@ -0,0 +1,5 @@ +/* +ollama implements an API client for ollama +https://github.com/ollama/ollama/blob/main/docs/api.md +*/ +package ollama diff --git a/pkg/ollama/embedding.go b/pkg/ollama/embedding.go new file mode 100644 index 0000000..1c69e07 --- /dev/null +++ b/pkg/ollama/embedding.go @@ -0,0 +1,95 @@ +package ollama + +import ( + "context" + "encoding/json" + "time" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// model is the implementation of the llm.Embedding interface +type embedding struct { + EmbeddingMeta +} + +// EmbeddingMeta is the metadata for a generated embedding vector +type EmbeddingMeta struct { + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` + Metrics +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m embedding) String() string { + data, err := json.MarshalIndent(m.EmbeddingMeta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +func (m EmbeddingMeta) String() string { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +type reqEmbedding struct { + Model string `json:"model"` + Input []string `json:"input"` + KeepAlive *time.Duration `json:"keep_alive,omitempty"` + Truncate *bool `json:"truncate,omitempty"` + Options map[string]interface{} `json:"options,omitempty"` +} + +func (ollama *Client) GenerateEmbedding(ctx context.Context, name string, prompt []string, opts ...llm.Opt) (*EmbeddingMeta, error) { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil, err + } + + // Bail out is no prompt + if len(prompt) == 0 { + return nil, llm.ErrBadParameter.With("missing prompt") + } + + // Request + req, err := client.NewJSONRequest(reqEmbedding{ + Model: name, + Input: prompt, + Truncate: opt.truncate, + KeepAlive: opt.keepalive, + Options: opt.options, + }) + if err != nil { + return nil, err + } + + // Response + var response embedding + if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("embed")); err != nil { + return nil, err + } + + // Return success + return &response.EmbeddingMeta, nil +} + +// Embedding vector generation +func (ollama *Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { + return nil, llm.ErrNotImplemented +} diff --git a/pkg/ollama/embedding_test.go b/pkg/ollama/embedding_test.go new file mode 100644 index 0000000..77c854f --- /dev/null +++ b/pkg/ollama/embedding_test.go @@ -0,0 +1,28 @@ +package ollama_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" + assert "github.com/stretchr/testify/assert" +) + +func Test_embed_001(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + t.Run("Embedding", func(t *testing.T) { + assert := assert.New(t) + embedding, err := client.GenerateEmbedding(context.TODO(), "qwen:0.5b", []string{"world"}) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(embedding) + }) +} diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go new file mode 100644 index 0000000..fd1e662 --- /dev/null +++ b/pkg/ollama/message.go @@ -0,0 +1,89 @@ +package ollama + +import ( + "encoding/json" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Implementation of a message +type message struct { + MessageMeta +} + +var _ llm.Context = (*message)(nil) + +// Chat Message +type MessageMeta struct { + Role string `json:"role"` + Content string `json:"content"` + Images []Data `json:"images,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +// Data represents the raw binary data of an image file. +type Data []byte + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m message) String() string { + data, err := json.MarshalIndent(m.MessageMeta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Create user message context, with optional images +func (ollama *Client) UserPrompt(v string, opts ...llm.Opt) llm.Context { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil + } + + m := new(message) + m.MessageMeta.Role = "user" + m.MessageMeta.Content = v + if len(opt.data) > 0 { + m.MessageMeta.Images = make([]Data, len(opt.data)) + copy(m.MessageMeta.Images, opt.data) + } + + // Return success + return m +} + +// The result of a tool call +func (ollama *Client) ToolResult(v any) llm.Context { + m := new(message) + m.MessageMeta.Role = "tool" + + switch v := v.(type) { + case string: + m.MessageMeta.Content = v + default: + // Encode the result into json + data, err := json.Marshal(v) + if err != nil { + return nil + } + m.MessageMeta.Content = string(data) + } + + // Return success + return m +} + +// Return the role of a message +func (m message) Role() string { + return m.MessageMeta.Role +} diff --git a/pkg/ollama/model.go b/pkg/ollama/model.go new file mode 100644 index 0000000..62d7492 --- /dev/null +++ b/pkg/ollama/model.go @@ -0,0 +1,239 @@ +package ollama + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// model is the implementation of the llm.Model interface +type model struct { + ModelMeta +} + +var _ llm.Model = (*model)(nil) + +// ModelMeta is the metadata for an ollama model +type ModelMeta struct { + Name string `json:"name"` + Model string `json:"model,omitempty"` + ModifiedAt time.Time `json:"modified_at"` + Size int64 `json:"size,omitempty"` + Digest string `json:"digest,omitempty"` + Details ModelDetails `json:"details"` + File string `json:"modelfile,omitempty"` + Parameters string `json:"parameters,omitempty"` + Template string `json:"template,omitempty"` + Info ModelInfo `json:"model_info,omitempty"` +} + +// ModelDetails are the details of the model +type ModelDetails struct { + ParentModel string `json:"parent_model,omitempty"` + Format string `json:"format"` + Family string `json:"family"` + Families []string `json:"families"` + ParameterSize string `json:"parameter_size"` + QuantizationLevel string `json:"quantization_level"` +} + +// ModelInfo provides additional model parameters +type ModelInfo map[string]any + +type respListModel struct { + Models []*model `json:"models"` +} + +type reqGetModel struct { + Model string `json:"model"` +} + +type reqCreateModel struct { + Name string `json:"name"` + File string `json:"modelfile"` +} + +type reqPullModel struct { + Model string `json:"model"` + Insecure bool `json:"insecure,omitempty"` + Stream bool `json:"stream"` +} + +type reqCopyModel struct { + Source string `json:"source"` + Destination string `json:"destination"` +} + +// PullStatus provides the status of a pull operation in a callback function +type PullStatus struct { + Status string `json:"status"` + DigestName string `json:"digest,omitempty"` + TotalBytes int64 `json:"total,omitempty"` + CompletedBytes int64 `json:"completed,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m model) String() string { + data, err := json.MarshalIndent(m.ModelMeta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +func (m PullStatus) String() string { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Agent interface +func (ollama *Client) Models(ctx context.Context) ([]llm.Model, error) { + return ollama.ListModels(ctx) +} + +// List models +func (ollama *Client) ListModels(ctx context.Context) ([]llm.Model, error) { + // Send the request + var response respListModel + if err := ollama.DoWithContext(ctx, nil, &response, client.OptPath("tags")); err != nil { + return nil, err + } + + // Convert to llm.Model + result := make([]llm.Model, 0, len(response.Models)) + for _, model := range response.Models { + result = append(result, model) + } + + // Return models + return result, nil +} + +// List running models +func (ollama *Client) ListRunningModels(ctx context.Context) ([]llm.Model, error) { + // Send the request + var response respListModel + if err := ollama.DoWithContext(ctx, nil, &response, client.OptPath("ps")); err != nil { + return nil, err + } + + // Convert to llm.Model + result := make([]llm.Model, 0, len(response.Models)) + for _, model := range response.Models { + result = append(result, model) + } + + // Return models + return result, nil +} + +// Get model details +func (ollama *Client) GetModel(ctx context.Context, name string) (llm.Model, error) { + // Request + req, err := client.NewJSONRequest(reqGetModel{ + Model: name, + }) + if err != nil { + return nil, err + } + + // Response + var response model + if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("show")); err != nil { + return nil, err + } else { + response.ModelMeta.Name = name + } + + // Return success + return &response, nil +} + +// Copy a local model by name +func (ollama *Client) CopyModel(ctx context.Context, source, destination string) error { + req, err := client.NewJSONRequest(reqCopyModel{ + Source: source, + Destination: destination, + }) + if err != nil { + return err + } + + // Send the request + return ollama.Do(req, nil, client.OptPath("copy")) +} + +// Delete a local model by name +func (ollama *Client) DeleteModel(ctx context.Context, name string) error { + req, err := client.NewJSONRequestEx(http.MethodDelete, reqGetModel{ + Model: name, + }, client.ContentTypeAny) + if err != nil { + return err + } + + // Send the request + return ollama.Do(req, nil, client.OptPath("delete")) +} + +// Pull a remote model locally +func (c *Client) PullModel(ctx context.Context, name string, opts ...llm.Opt) error { + // Apply options + opt, err := apply(opts...) + if err != nil { + return err + } + + // Request + req, err := client.NewJSONRequest(reqPullModel{ + Model: name, + Stream: opt.stream, + Insecure: opt.insecure, + }) + if err != nil { + return err + } + + // Response + var response PullStatus + if err := c.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout(), client.OptJsonStreamCallback(func(v any) error { + if v, ok := v.(*PullStatus); ok && opt.pullcallback != nil { + opt.pullcallback(v) + } + return nil + })); err != nil { + return err + } + + // Check status + if response.Status != "success" { + return fmt.Errorf("Pull failed: %v", response.Status) + } + + // Return success + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func (m model) Name() string { + return m.ModelMeta.Name +} diff --git a/pkg/ollama/model_test.go b/pkg/ollama/model_test.go new file mode 100644 index 0000000..c19919d --- /dev/null +++ b/pkg/ollama/model_test.go @@ -0,0 +1,73 @@ +package ollama_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" + assert "github.com/stretchr/testify/assert" +) + +func Test_model_001(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + var names []string + t.Run("Models", func(t *testing.T) { + assert := assert.New(t) + models, err := client.Models(context.TODO()) + if !assert.NoError(err) { + t.FailNow() + } + assert.NotNil(models) + for _, model := range models { + names = append(names, model.Name()) + } + }) + + t.Run("Model", func(t *testing.T) { + assert := assert.New(t) + for _, name := range names { + model, err := client.GetModel(context.TODO(), name) + if !assert.NoError(err) { + t.FailNow() + } + t.Log(model) + } + }) + + t.Run("PullModel", func(t *testing.T) { + assert := assert.New(t) + err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + t.Log(status) + })) + if !assert.NoError(err) { + t.FailNow() + } + }) + + t.Run("CopyModel", func(t2 *testing.T) { + assert := assert.New(t) + err := client.CopyModel(context.TODO(), "qwen:0.5b", t.Name()) + if !assert.NoError(err) { + t.FailNow() + } + }) + + t.Run("DeleteModel", func(t2 *testing.T) { + assert := assert.New(t) + _, err = client.GetModel(context.TODO(), t.Name()) + if !assert.NoError(err) { + t.FailNow() + } + err := client.DeleteModel(context.TODO(), t.Name()) + if !assert.NoError(err) { + t.FailNow() + } + }) +} diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go new file mode 100644 index 0000000..a8a6079 --- /dev/null +++ b/pkg/ollama/opt.go @@ -0,0 +1,132 @@ +package ollama + +import ( + "io" + "time" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opt struct { + format string + stream bool + pullcallback func(*PullStatus) + chatcallback func(*Response) + insecure bool + truncate *bool + keepalive *time.Duration + options map[string]any + context []*MessageMeta + tools []*Tool + data []Data +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func apply(opts ...llm.Opt) (*opt, error) { + o := new(opt) + o.options = make(map[string]any) + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + return o, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// OPTIONS + +// Pull Model: Allow insecure connections for pulling models. +func WithInsecure() llm.Opt { + return func(o any) error { + o.(*opt).insecure = true + return nil + } +} + +// Embeddings: Does not truncate the end of each input to fit within context length. Returns error if context length is exceeded. +func WithTruncate(v bool) llm.Opt { + return func(o any) error { + o.(*opt).truncate = &v + return nil + } +} + +// Embeddings & Chat: Controls how long the model will stay loaded into memory following the request. +func WithKeepAlive(v time.Duration) llm.Opt { + return func(o any) error { + o.(*opt).keepalive = &v + return nil + } +} + +// Pull Model: Stream the response as it is received. +func WithPullStatus(fn func(*PullStatus)) llm.Opt { + return func(o any) error { + if fn == nil { + o.(*opt).stream = false + o.(*opt).pullcallback = nil + } else { + o.(*opt).stream = true + o.(*opt).pullcallback = fn + } + return nil + } +} + +// Chat: Stream the response as it is received. +func WithChatStream(fn func(*Response)) llm.Opt { + return func(o any) error { + if fn == nil { + return llm.ErrBadParameter.With("callback required") + } + if len(o.(*opt).tools) > 0 { + return llm.ErrBadParameter.With("tools not supported with streaming") + } + o.(*opt).stream = true + o.(*opt).chatcallback = fn + return nil + } +} + +// Chat: Append a tool to the request. +func WithTool(v *Tool) llm.Opt { + return func(o any) error { + // We can't use streaming when tools are included + if o.(*opt).stream { + return llm.ErrBadParameter.With("streaming not supported with tools") + } + if v != nil { + o.(*opt).tools = append(o.(*opt).tools, v) + } + return nil + } +} + +// Embeddings & Chat: model-specific options. +func WithOption(key string, value any) llm.Opt { + return func(o any) error { + if value != nil && key != "" { + o.(*opt).options[key] = value + } + return nil + } +} + +// Chat: attach data. +func WithData(r io.Reader) llm.Opt { + return func(o any) error { + data, err := io.ReadAll(r) + if err != nil { + return err + } + o.(*opt).data = append(o.(*opt).data, data) + return nil + } +} diff --git a/pkg/ollama/testdata/guggenheim.jpg b/pkg/ollama/testdata/guggenheim.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7e1651729c5008c35446d52049de346f86962ef3 GIT binary patch literal 139053 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", v) + return nil +} diff --git a/pkg/ollama/tool_test.go b/pkg/ollama/tool_test.go new file mode 100644 index 0000000..f4d1d30 --- /dev/null +++ b/pkg/ollama/tool_test.go @@ -0,0 +1,29 @@ +package ollama_test + +import ( + "testing" + + // Packagees + + ollama "github.com/mutablelogic/go-llm/pkg/ollama" +) + +func Test_tool_001(t *testing.T) { + tool, err := ollama.NewTool("test", "test_tool", struct{}{}) + if err != nil { + t.FailNow() + } + t.Log(tool) +} + +func Test_tool_002(t *testing.T) { + tool, err := ollama.NewTool("test", "test_tool", struct { + A string `help:"A string"` + B int `help:"An integer"` + C float64 `help:"A float" required:""` + }{}) + if err != nil { + t.FailNow() + } + t.Log(tool) +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..956f783 --- /dev/null +++ b/response.go @@ -0,0 +1,29 @@ +package llm + +import ( + "encoding/json" + "time" +) + +////////////////////////////////////////////////////////////////// +// TYPES + +type Response struct { + Agent string `json:"agent,omitempty"` // The agent name + Model string `json:"model,omitempty"` // The model name + Context []Context `json:"context,omitempty"` // The context for the response + Text string `json:"text,omitempty"` // The response text + Tokens uint `json:"tokens,omitempty"` // The number of tokens + Duration time.Duration `json:"duration,omitempty"` // The response duration +} + +////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (r Response) String() string { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} diff --git a/tool.go b/tool.go new file mode 100644 index 0000000..53e9e4c --- /dev/null +++ b/tool.go @@ -0,0 +1,96 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "strconv" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +// A tool can be called from an LLM +type Tool interface { + // Return the provider of the tool + Provider() string + + // Return the name of the tool + Name() string + + // Return the description of the tool + Description() string + + // Tool parameters + Params() []ToolParameter + + // Execute the tool with a specific tool + Run(context.Context, *ToolCall) (*ToolResult, error) +} + +// A tool parameter +type ToolParameter struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` +} + +// A call to a tool +type ToolCall struct { + Id string `json:"id"` + Name string `json:"name"` + Args map[string]any `json:"args"` +} + +// The result of a tool call +type ToolResult struct { + Id string `json:"id"` + Result map[string]any `json:"result,omitempty"` +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return the arguments for the call as a JSON +func (t *ToolCall) Json() string { + data, err := json.MarshalIndent(t.Args, "", " ") + if err != nil { + return err.Error() + } else { + return string(data) + } +} + +// Return role for the tool result +func (t *ToolResult) Role() string { + return "tool" +} + +// Return parameter as a string +func (t *ToolCall) String(name string) (string, error) { + v, ok := t.Args[name] + if !ok { + return "", ErrNotFound.Withf("%q not found", name) + } + return fmt.Sprint(v), nil +} + +// Return parameter as an integer +func (t *ToolCall) Int(name string) (int, error) { + v, ok := t.Args[name] + if !ok { + return 0, ErrNotFound.Withf("%q not found", name) + } + switch v := v.(type) { + case int: + return v, nil + case string: + if v_, err := strconv.ParseInt(v, 10, 32); err != nil { + return 0, ErrBadParameter.Withf("%q: %v", name, err) + } else { + return int(v_), nil + } + default: + return 0, ErrBadParameter.Withf("%q: Expected integer, got %T", name, v) + } +} From ecd5a72760c6da5a9e2bea56905f2e757c8258ce Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 10:01:43 +0100 Subject: [PATCH 02/33] Updates --- pkg/agent/agent.go | 120 ++++++++++++++++++++++++++++++++++++++++ pkg/agent/agent_test.go | 45 +++++++++++++++ pkg/agent/opt.go | 1 - 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 pkg/agent/agent_test.go diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index c5854d8..a87a073 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -1,6 +1,12 @@ package agent import ( + "context" + "encoding/json" + "errors" + "slices" + "strings" + // Packages llm "github.com/mutablelogic/go-llm" ) @@ -12,6 +18,11 @@ type agent struct { *opt } +type model struct { + Agent string `json:"agent"` + llm.Model `json:"model"` +} + var _ llm.Agent = (*agent)(nil) /////////////////////////////////////////////////////////////////////////////// @@ -31,5 +42,114 @@ func New(opts ...llm.Opt) (*agent, error) { return agent, nil } +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m model) String() string { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS + +// Return a list of agent names +func (a *agent) Agents() []string { + var keys []string + for k := range a.agents { + keys = append(keys, k) + } + return keys +} + +// Return a comma-separated list of agent names +func (a *agent) Name() string { + return strings.Join(a.Agents(), ",") +} + +// Return the models from all agents +func (a *agent) Models(ctx context.Context) ([]llm.Model, error) { + var result error + + models := make([]llm.Model, 0, 100) + for _, agent := range a.agents { + agentmodels, err := modelsForAgent(ctx, agent) + if err != nil { + result = errors.Join(result, err) + continue + } else { + models = append(models, agentmodels...) + } + } + + // Return the models with any errors + return models, result +} + +// Return a model by name. If no agents are specified, then all agents are considered. +// If multiple agents are specified, then the first model found is returned. +func (a *agent) Model(ctx context.Context, name string, agent ...string) (llm.Model, error) { + if len(agent) == 0 { + agent = a.Agents() + } + + var result error + for _, agent := range agent { + models, err := modelsForAgent(ctx, a.agents[agent], name) + if err != nil { + result = errors.Join(result, err) + continue + } else if len(models) > 0 { + return models[0], nil + } + } + + // Return any errors + return nil, result +} + +// Generate a response from a prompt +func (a *agent) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { + return nil, llm.ErrNotImplemented +} + +// Embedding vector generation +func (a *agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { + return nil, llm.ErrNotImplemented +} + +// Create user message context +func (a *agent) UserPrompt(string, ...llm.Opt) llm.Context { + return nil +} + +// Create the result of calling a tool +func (a *agent) ToolResult(any) llm.Context { + return nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func modelsForAgent(ctx context.Context, agent llm.Agent, names ...string) ([]llm.Model, error) { + // Gather models + models, err := agent.Models(ctx) + if err != nil { + return nil, err + } + + // Filter models + result := make([]llm.Model, 0, len(models)) + for _, agentmodel := range models { + if len(names) > 0 && !slices.Contains(names, agentmodel.Name()) { + continue + } + result = append(result, &model{Agent: agent.Name(), Model: agentmodel}) + } + + // Return success + return result, nil +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 0000000..5b78d81 --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,45 @@ +package agent_test + +import ( + "context" + "os" + "testing" + + // Packages + llm "github.com/mutablelogic/go-llm" + agent "github.com/mutablelogic/go-llm/pkg/agent" + assert "github.com/stretchr/testify/assert" +) + +func Test_client_001(t *testing.T) { + assert := assert.New(t) + + opts := []llm.Opt{} + opts = append(opts, GetOllamaEndpoint(t)...) + + // Create a client + client, err := agent.New(opts...) + if assert.NoError(err) { + assert.NotNil(client) + + // Get models + models, err := client.Models(context.TODO()) + if !assert.NoError(err) { + t.FailNow() + } + assert.NotNil(models) + t.Log(models) + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ENVIRONMENT + +func GetOllamaEndpoint(t *testing.T) []llm.Opt { + key := os.Getenv("OLLAMA_URL") + if key == "" { + return []llm.Opt{} + } else { + return []llm.Opt{agent.WithOllama(key)} + } +} diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index de17379..0ff13c3 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -31,7 +31,6 @@ func apply(opts ...llm.Opt) (*opt, error) { //////////////////////////////////////////////////////////////////////////////// // OPTIONS -// Ollama func WithOllama(endpoint string, opts ...client.ClientOpt) llm.Opt { return func(o any) error { client, err := ollama.New(endpoint, opts...) From 4b9e6c57e8c28f0205a83e4b59e03d6575b0471d Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 11:11:14 +0100 Subject: [PATCH 03/33] Added anthropic --- cmd/agent/main.go | 114 +++++++++++++++++++++++++++++++++ cmd/agent/models.go | 35 ++++++++++ pkg/agent/opt.go | 37 ++++++++--- pkg/anthropic/client.go | 77 ++++++++++++++++++++++ pkg/anthropic/client_test.go | 32 +++++++++ pkg/anthropic/messages.go | 45 +++++++++++++ pkg/anthropic/messages_test.go | 34 ++++++++++ pkg/anthropic/model.go | 90 ++++++++++++++++++++++++++ pkg/anthropic/opt.go | 38 +++++++++++ 9 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 cmd/agent/main.go create mode 100644 cmd/agent/models.go create mode 100644 pkg/anthropic/client.go create mode 100644 pkg/anthropic/client_test.go create mode 100644 pkg/anthropic/messages.go create mode 100644 pkg/anthropic/messages_test.go create mode 100644 pkg/anthropic/model.go create mode 100644 pkg/anthropic/opt.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..f1d8b96 --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "os" + "os/signal" + "path/filepath" + "syscall" + + // Packages + kong "github.com/alecthomas/kong" + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" + "github.com/mutablelogic/go-llm/pkg/agent" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Globals struct { + // Debugging + Debug bool `name:"debug" help:"Enable debug output"` + Verbose bool `name:"verbose" help:"Enable verbose output"` + + // Agents + Ollama `embed:"" help:"Ollama configuration"` + Anthropic `embed:"" help:"Anthropic configuration"` + + // Context + ctx context.Context + agent llm.Agent +} + +type Ollama struct { + OllamaEndpoint string `env:"OLLAMA_URL" help:"Ollama endpoint"` +} + +type Anthropic struct { + AnthropicKey string `env:"ANTHROPIC_API_KEY" help:"Anthropic API Key"` +} + +type CLI struct { + Globals + + // Agents, Models and Tools + //Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` + Models ListModelsCmd `cmd:"" help:"Return a list of models"` +} + +//////////////////////////////////////////////////////////////////////////////// +// MAIN + +func main() { + // Create a cli parser + cli := CLI{} + cmd := kong.Parse(&cli, + kong.Name(execName()), + kong.Description("Agent command line interface"), + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{Compact: true}), + kong.Vars{}, + ) + + // Create a context + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + cli.Globals.ctx = ctx + + // Client options + clientopts := []client.ClientOpt{} + if cli.Debug || cli.Verbose { + clientopts = append(clientopts, client.OptTrace(os.Stderr, cli.Verbose)) + } + + // Create an agent + opts := []llm.Opt{} + if cli.OllamaEndpoint != "" { + opts = append(opts, agent.WithOllama(cli.OllamaEndpoint, clientopts...)) + } + if cli.AnthropicKey != "" { + opts = append(opts, agent.WithAnthropic(cli.AnthropicKey, clientopts...)) + } + + agent, err := agent.New(opts...) + cmd.FatalIfErrorf(err) + cli.Globals.agent = agent + + // Run the command + if err := cmd.Run(&cli.Globals); err != nil { + cmd.FatalIfErrorf(err) + return + } +} + +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func execName() string { + // The name of the executable + name, err := os.Executable() + if err != nil { + panic(err) + } else { + return filepath.Base(name) + } +} + +func clientOpts(cli *CLI) []client.ClientOpt { + result := []client.ClientOpt{} + if cli.Debug { + result = append(result, client.OptTrace(os.Stderr, cli.Verbose)) + } + return result +} diff --git a/cmd/agent/models.go b/cmd/agent/models.go new file mode 100644 index 0000000..374803c --- /dev/null +++ b/cmd/agent/models.go @@ -0,0 +1,35 @@ +package main + +import ( + "context" + "fmt" + + llm "github.com/mutablelogic/go-llm" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ListModelsCmd struct { +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (*ListModelsCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, agent llm.Agent) error { + models, err := agent.Models(ctx) + if err != nil { + return err + } + fmt.Println(models) + return nil + }) +} + +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func runagent(globals *Globals, fn func(ctx context.Context, agent llm.Agent) error) error { + return fn(globals.ctx, globals.agent) +} diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index 0ff13c3..b1a9c2a 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -2,9 +2,10 @@ package agent import ( // Packages - "github.com/mutablelogic/go-client" + client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" - "github.com/mutablelogic/go-llm/pkg/ollama" + anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" ) //////////////////////////////////////////////////////////////////////////////// @@ -29,20 +30,40 @@ func apply(opts ...llm.Opt) (*opt, error) { } //////////////////////////////////////////////////////////////////////////////// -// OPTIONS +// PUBLIC METHODS func WithOllama(endpoint string, opts ...client.ClientOpt) llm.Opt { return func(o any) error { client, err := ollama.New(endpoint, opts...) if err != nil { return err + } else { + return o.(*opt).withAgent(client) } - name := client.Name() - if _, exists := o.(*opt).agents[name]; exists { - return llm.ErrConflict.Withf("Agent %q already exists", name) + } +} + +func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { + return func(o any) error { + client, err := anthropic.New(key, opts...) + if err != nil { + return err } else { - o.(*opt).agents[name] = client + return o.(*opt).withAgent(client) } - return nil } } + +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func (o *opt) withAgent(agent llm.Agent) error { + name := agent.Name() + if _, exists := o.agents[name]; exists { + return llm.ErrConflict.Withf("Agent %q already exists", name) + } else { + o.agents[name] = agent + } + // Return success + return nil +} diff --git a/pkg/anthropic/client.go b/pkg/anthropic/client.go new file mode 100644 index 0000000..e70c996 --- /dev/null +++ b/pkg/anthropic/client.go @@ -0,0 +1,77 @@ +/* +anthropic implements an API client for anthropic (https://docs.anthropic.com/en/api/getting-started) +*/ +package anthropic + +import ( + "context" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Client struct { + *client.Client +} + +var _ llm.Agent = (*Client)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + endPoint = "https://api.anthropic.com/v1" + defaultVersion = "2023-06-01" + defaultName = "anthropic" + defaultMessageModel = "claude-3-haiku-20240307" + defaultMaxTokens = 1024 +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create a new client +func New(ApiKey string, opts ...client.ClientOpt) (*Client, error) { + // Create client + opts = append(opts, client.OptEndpoint(endPoint)) + opts = append(opts, client.OptHeader("x-api-key", ApiKey), client.OptHeader("anthropic-version", defaultVersion)) + client, err := client.New(opts...) + if err != nil { + return nil, err + } + + // Return the client + return &Client{client}, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return the name of the agent +func (*Client) Name() string { + return defaultName +} + +// Generate a response from a prompt +func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { + return nil, llm.ErrNotImplemented +} + +// Embedding vector generation +func (*Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { + return nil, llm.ErrNotImplemented +} + +// Create user message context +func (*Client) UserPrompt(string, ...llm.Opt) llm.Context { + return nil +} + +// Create the result of calling a tool +func (*Client) ToolResult(any) llm.Context { + return nil +} diff --git a/pkg/anthropic/client_test.go b/pkg/anthropic/client_test.go new file mode 100644 index 0000000..ea7e2ee --- /dev/null +++ b/pkg/anthropic/client_test.go @@ -0,0 +1,32 @@ +package anthropic_test + +import ( + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + assert "github.com/stretchr/testify/assert" +) + +func Test_client_001(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } +} + +/////////////////////////////////////////////////////////////////////////////// +// ENVIRONMENT + +func GetApiKey(t *testing.T) string { + key := os.Getenv("ANTHROPIC_API_KEY") + if key == "" { + t.Skip("ANTHROPIC_API_KEY not set, skipping tests") + t.SkipNow() + } + return key +} diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go new file mode 100644 index 0000000..4cad1f3 --- /dev/null +++ b/pkg/anthropic/messages.go @@ -0,0 +1,45 @@ +package anthropic + +import ( + "context" + + // Packages + "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +/////////////////////////////////////////////////////////////////////////////// +// MESSAGES + +type reqMessages struct { + Model string `json:"model"` + *opt +} + +func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) error { + // Apply options + opt, err := apply(opts...) + if err != nil { + return err + } + + // Request + req, err := client.NewJSONRequest(reqMessages{ + Model: model.Name(), + opt: opt, + }) + if err != nil { + return err + } + + // Response + if err := anthropic.DoWithContext(ctx, req, nil, client.OptPath("messages")); err != nil { + return err + } + + // Return success + return nil +} diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go new file mode 100644 index 0000000..8f1fe81 --- /dev/null +++ b/pkg/anthropic/messages_test.go @@ -0,0 +1,34 @@ +package anthropic_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + assert "github.com/stretchr/testify/assert" +) + +func Test_messages_001(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } else { + t.FailNow() + } + + err = client.Messages(context.TODO(), model, client.UserPrompt("hello world")) + if assert.NoError(err) { + t.Log("OK") + } +} diff --git a/pkg/anthropic/model.go b/pkg/anthropic/model.go new file mode 100644 index 0000000..ff94151 --- /dev/null +++ b/pkg/anthropic/model.go @@ -0,0 +1,90 @@ +package anthropic + +import ( + "context" + "net/url" + "time" + + // Packages + client "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// model is the implementation of the llm.Model interface +type model struct { + ModelMeta +} + +var _ llm.Model = (*model)(nil) + +// ModelMeta is the metadata for an anthropic model +type ModelMeta struct { + Name string `json:"id"` + Description string `json:"display_name,omitempty"` + Type string `json:"type,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Agent interface +func (anthropic *Client) Models(ctx context.Context) ([]llm.Model, error) { + return anthropic.ListModels(ctx) +} + +// Get a model by name +func (anthropic *Client) GetModel(ctx context.Context, name string) (llm.Model, error) { + + var response ModelMeta + if err := anthropic.DoWithContext(ctx, nil, &response, client.OptPath("models", name)); err != nil { + return nil, err + } + + // Return success + return &model{ModelMeta: response}, nil +} + +// List models +func (anthropic *Client) ListModels(ctx context.Context) ([]llm.Model, error) { + // Send the request + var response struct { + Body []ModelMeta `json:"data"` + HasMore bool `json:"has_more"` + FirstId string `json:"first_id"` + LastId string `json:"last_id"` + } + + request := url.Values{} + result := make([]llm.Model, 0, 100) + for { + if err := anthropic.DoWithContext(ctx, nil, &response, client.OptPath("models"), client.OptQuery(request)); err != nil { + return nil, err + } + + // Convert to llm.Model + for _, meta := range response.Body { + result = append(result, &model{ + ModelMeta: meta, + }) + } + + // If there are no more models, return + if !response.HasMore { + break + } else { + request.Set("after_id", response.LastId) + } + } + + // Return models + return result, nil +} + +// Return the name of a model +func (model *model) Name() string { + return model.ModelMeta.Name +} diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go new file mode 100644 index 0000000..7e5c4a7 --- /dev/null +++ b/pkg/anthropic/opt.go @@ -0,0 +1,38 @@ +package anthropic + +import ( + // Packages + llm "github.com/mutablelogic/go-llm" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opt struct { + MaxTokens uint `json:"max_tokens,omitempty"` + Metadata struct { + User string `json:"user_id,omitempty"` + } `json:"metadata,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream *bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopK uint `json:"top_k,omitempty"` + TopP float64 `json:"top_p,omitempty"` +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func apply(opts ...llm.Opt) (*opt, error) { + o := new(opt) + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + return o, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// OPTIONS From a8ca7547838a8f30805508eb7d358f98fbc9671f Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 11:50:13 +0100 Subject: [PATCH 04/33] Anthropic messages --- pkg/anthropic/client.go | 18 ++----- pkg/anthropic/message.go | 64 +++++++++++++++++++++++++ pkg/anthropic/messages.go | 88 +++++++++++++++++++++++++++++----- pkg/anthropic/messages_test.go | 4 +- pkg/anthropic/opt.go | 25 ++++++---- 5 files changed, 161 insertions(+), 38 deletions(-) create mode 100644 pkg/anthropic/message.go diff --git a/pkg/anthropic/client.go b/pkg/anthropic/client.go index e70c996..6e78a72 100644 --- a/pkg/anthropic/client.go +++ b/pkg/anthropic/client.go @@ -24,11 +24,9 @@ var _ llm.Agent = (*Client)(nil) // GLOBALS const ( - endPoint = "https://api.anthropic.com/v1" - defaultVersion = "2023-06-01" - defaultName = "anthropic" - defaultMessageModel = "claude-3-haiku-20240307" - defaultMaxTokens = 1024 + endPoint = "https://api.anthropic.com/v1" + defaultVersion = "2023-06-01" + defaultName = "anthropic" ) /////////////////////////////////////////////////////////////////////////////// @@ -65,13 +63,3 @@ func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*l func (*Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { return nil, llm.ErrNotImplemented } - -// Create user message context -func (*Client) UserPrompt(string, ...llm.Opt) llm.Context { - return nil -} - -// Create the result of calling a tool -func (*Client) ToolResult(any) llm.Context { - return nil -} diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go new file mode 100644 index 0000000..fa74491 --- /dev/null +++ b/pkg/anthropic/message.go @@ -0,0 +1,64 @@ +package anthropic + +import ( + "encoding/json" + + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Implementation of a message +type message struct { + MessageMeta +} + +var _ llm.Context = (*message)(nil) + +// Message with text or object content +type MessageMeta struct { + Role string `json:"role"` + Content any `json:"content"` +} + +type Attachment struct { + Type string `json:"type"` // image, document + Source struct { + Type string `json:"type"` // base64 + MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp, application/pdf + Data string `json:"data"` // ...base64 encoded data + CacheControl string `json:"cache_control,omitempty"` // ephemeral + } `json:"source"` +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (m message) String() string { + data, err := json.MarshalIndent(m.MessageMeta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (m message) Role() string { + return m.MessageMeta.Role +} + +// Create user message context +func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { + context := &message{} + context.MessageMeta.Role = "user" + context.MessageMeta.Content = text + return context +} + +// Create the result of calling a tool +func (*Client) ToolResult(any) llm.Context { + return nil +} diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 4cad1f3..31ab435 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -2,44 +2,110 @@ package anthropic import ( "context" + "encoding/json" + "strings" // Packages - "github.com/mutablelogic/go-client" + client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" ) /////////////////////////////////////////////////////////////////////////////// // TYPES +// Messages Response +type Response struct { + Type string `json:"type"` + Model string `json:"model"` + Id string `json:"id"` + MessageMeta + Reason string `json:"stop_reason,omitempty"` + StopSequence *string `json:"stop_sequence,omitempty"` + Metrics `json:"usage,omitempty"` +} + +// Metrics +type Metrics struct { + CacheCreationInputTokens uint `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens uint `json:"cache_read_input_tokens,omitempty"` + InputTokens uint `json:"input_tokens,omitempty"` + OutputTokens uint `json:"output_tokens,omitempty"` +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (r Response) String() string { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + /////////////////////////////////////////////////////////////////////////////// -// MESSAGES +// PUBLIC METHODS type reqMessages struct { - Model string `json:"model"` + Model string `json:"model"` + Messages []*MessageMeta `json:"messages"` *opt } -func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) error { +func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (*Response, error) { // Apply options opt, err := apply(opts...) if err != nil { - return err + return nil, err + } + + // Context to append to the request + messages := []*MessageMeta{} + message, ok := context.(*message) + if !ok || message == nil { + return nil, llm.ErrBadParameter.With("incompatible context") + } else if message.Role() != "user" { + return nil, llm.ErrBadParameter.Withf("invalid role, %q", message.Role()) + } else { + messages = append(messages, &message.MessageMeta) + } + + // Set max_tokens + if opt.MaxTokens == 0 { + opt.MaxTokens = defaultMaxTokens(model.Name()) } // Request req, err := client.NewJSONRequest(reqMessages{ - Model: model.Name(), - opt: opt, + Model: model.Name(), + Messages: messages, + opt: opt, }) if err != nil { - return err + return nil, err } // Response - if err := anthropic.DoWithContext(ctx, req, nil, client.OptPath("messages")); err != nil { - return err + var response Response + if err := anthropic.DoWithContext(ctx, req, &response, client.OptPath("messages")); err != nil { + return nil, err } // Return success - return nil + return &response, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func defaultMaxTokens(model string) uint { + // https://docs.anthropic.com/en/docs/about-claude/models + switch { + case strings.Contains(model, "claude-3-5-haiku"): + return 8192 + case strings.Contains(model, "claude-3-5-sonnet"): + return 8192 + default: + return 4096 + } } diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 8f1fe81..949d2d0 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -27,8 +27,8 @@ func Test_messages_001(t *testing.T) { t.FailNow() } - err = client.Messages(context.TODO(), model, client.UserPrompt("hello world")) + response, err := client.Messages(context.TODO(), model, client.UserPrompt("hello world")) if assert.NoError(err) { - t.Log("OK") + t.Log(response) } } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 7e5c4a7..d0142ea 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -2,6 +2,7 @@ package anthropic import ( // Packages + llm "github.com/mutablelogic/go-llm" ) @@ -9,16 +10,20 @@ import ( // TYPES type opt struct { - MaxTokens uint `json:"max_tokens,omitempty"` - Metadata struct { - User string `json:"user_id,omitempty"` - } `json:"metadata,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - Stream *bool `json:"stream,omitempty"` - System string `json:"system,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopK uint `json:"top_k,omitempty"` - TopP float64 `json:"top_p,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Metadata *optmetadata `json:"metadata,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream *bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopK uint `json:"top_k,omitempty"` + TopP float64 `json:"top_p,omitempty"` + + data []Attachment +} + +type optmetadata struct { + User string `json:"user_id,omitempty"` } //////////////////////////////////////////////////////////////////////////////// From e2614e4ab16ee226115f72a37bf66a8846e30425 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 11:55:19 +0100 Subject: [PATCH 05/33] Added opts --- pkg/anthropic/opt.go | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index d0142ea..af0b61f 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -41,3 +41,58 @@ func apply(opts ...llm.Opt) (*opt, error) { //////////////////////////////////////////////////////////////////////////////// // OPTIONS + +func WithTemperature(v float64) llm.Opt { + return func(o any) error { + if v < 0.0 || v > 1.0 { + return llm.ErrBadParameter.With("temperature must be between 0.0 and 1.0") + } + o.(*opt).Temperature = v + return nil + } +} + +func WithSystem(v string) llm.Opt { + return func(o any) error { + o.(*opt).System = v + return nil + } +} + +func WithMaxTokens(v uint) llm.Opt { + return func(o any) error { + o.(*opt).MaxTokens = v + return nil + } +} + +func WithUser(v string) llm.Opt { + return func(o any) error { + o.(*opt).Metadata = &optmetadata{User: v} + return nil + } +} + +func WithStopSequences(v ...string) llm.Opt { + return func(o any) error { + o.(*opt).StopSequences = v + return nil + } +} + +func WithTopP(v float64) llm.Opt { + return func(o any) error { + if v < 0.0 || v > 1.0 { + return llm.ErrBadParameter.With("top_p must be between 0.0 and 1.0") + } + o.(*opt).TopP = v + return nil + } +} + +func WithTopK(v uint) llm.Opt { + return func(o any) error { + o.(*opt).TopK = v + return nil + } +} From 20ef89fedc4cefec85a93f3c3b2282ebabf64e1e Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 12:13:22 +0100 Subject: [PATCH 06/33] Added anthropic attachments --- pkg/anthropic/message.go | 69 +++++++++++++++++++++++++- pkg/anthropic/messages_test.go | 8 ++- pkg/anthropic/opt.go | 17 ++++++- pkg/anthropic/testdata/guggenheim.jpg | Bin 0 -> 139053 bytes 4 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 pkg/anthropic/testdata/guggenheim.jpg diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index fa74491..d8079f3 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -1,7 +1,10 @@ package anthropic import ( + "bytes" "encoding/json" + "io" + "net/http" llm "github.com/mutablelogic/go-llm" ) @@ -27,11 +30,16 @@ type Attachment struct { Source struct { Type string `json:"type"` // base64 MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp, application/pdf - Data string `json:"data"` // ...base64 encoded data + Data []byte `json:"data"` // ...base64 encoded data CacheControl string `json:"cache_control,omitempty"` // ephemeral } `json:"source"` } +type Text struct { + Type string `json:"type"` // text + Text string `json:"text"` +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY @@ -52,9 +60,27 @@ func (m message) Role() string { // Create user message context func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { + // Get attachments + opt, err := apply(opts...) + if err != nil { + return nil + } + context := &message{} context.MessageMeta.Role = "user" - context.MessageMeta.Content = text + if len(opt.data) > 0 { + content := make([]any, 0, len(opt.data)+1) + content = append(content, &Text{ + Type: "text", + Text: text, + }) + for _, data := range opt.data { + content = append(content, data) + } + context.MessageMeta.Content = content + } else { + context.MessageMeta.Content = text + } return context } @@ -62,3 +88,42 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { func (*Client) ToolResult(any) llm.Context { return nil } + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +var ( + supportedAttachments = map[string]string{ + "image/jpeg": "image", + "image/png": "image", + "image/gif": "image", + "image/webp": "image", + "application/pdf": "document", + } +) + +// Create a new attachment from an io.Reader +func NewAttachment(r io.Reader) (*Attachment, error) { + var data bytes.Buffer + if _, err := io.Copy(&data, r); err != nil { + return nil, err + } + + // Detect mimetype + mimetype := http.DetectContentType(data.Bytes()) + typ, exists := supportedAttachments[mimetype] + if !exists { + return nil, llm.ErrBadParameter.Withf("unsupported or undetected mimetype %q", mimetype) + } + + // Create attachment + attachment := &Attachment{ + Type: typ, + } + attachment.Source.Type = "base64" + attachment.Source.MediaType = mimetype + attachment.Source.Data = data.Bytes() + + // Return success + return attachment, nil +} diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 949d2d0..fd2b181 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -27,7 +27,13 @@ func Test_messages_001(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model, client.UserPrompt("hello world")) + f, err := os.Open("testdata/guggenheim.jpg") + if !assert.NoError(err) { + t.FailNow() + } + defer f.Close() + + response, err := client.Messages(context.TODO(), model, client.UserPrompt("what is this image?", anthropic.WithData(f))) if assert.NoError(err) { t.Log(response) } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index af0b61f..283a21f 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -1,8 +1,9 @@ package anthropic import ( - // Packages + "io" + // Packages llm "github.com/mutablelogic/go-llm" ) @@ -19,7 +20,8 @@ type opt struct { TopK uint `json:"top_k,omitempty"` TopP float64 `json:"top_p,omitempty"` - data []Attachment + // Attachments for messages + data []*Attachment } type optmetadata struct { @@ -42,6 +44,17 @@ func apply(opts ...llm.Opt) (*opt, error) { //////////////////////////////////////////////////////////////////////////////// // OPTIONS +func WithData(r io.Reader) llm.Opt { + return func(o any) error { + attachment, err := NewAttachment(r) + if err != nil { + return err + } + o.(*opt).data = append(o.(*opt).data, attachment) + return nil + } +} + func WithTemperature(v float64) llm.Opt { return func(o any) error { if v < 0.0 || v > 1.0 { diff --git a/pkg/anthropic/testdata/guggenheim.jpg b/pkg/anthropic/testdata/guggenheim.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7e1651729c5008c35446d52049de346f86962ef3 GIT binary patch literal 139053 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 Date: Wed, 29 Jan 2025 13:07:46 +0100 Subject: [PATCH 07/33] Updated messages --- pkg/anthropic/message.go | 126 +++++++++++++++------ pkg/anthropic/messages_test.go | 30 ++++- pkg/anthropic/opt.go | 8 +- pkg/anthropic/testdata/LICENSE | 201 +++++++++++++++++++++++++++++++++ 4 files changed, 328 insertions(+), 37 deletions(-) create mode 100644 pkg/anthropic/testdata/LICENSE diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index d8079f3..e5041c5 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -5,6 +5,8 @@ import ( "encoding/json" "io" "net/http" + "os" + "strings" llm "github.com/mutablelogic/go-llm" ) @@ -21,23 +23,32 @@ var _ llm.Context = (*message)(nil) // Message with text or object content type MessageMeta struct { - Role string `json:"role"` - Content any `json:"content"` + Role string `json:"role"` + Content []*Content `json:"content,omitempty"` } -type Attachment struct { - Type string `json:"type"` // image, document - Source struct { - Type string `json:"type"` // base64 - MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp, application/pdf - Data []byte `json:"data"` // ...base64 encoded data - CacheControl string `json:"cache_control,omitempty"` // ephemeral - } `json:"source"` +type Content struct { + Type string `json:"type"` // image, document, text + Text string `json:"text,omitempty"` // text content + Title string `json:"title,omitempty"` // title of the document + Context string `json:"context,omitempty"` // context of the document + Citations *contentcitation `json:"citations,omitempty"` // citations of the document + Source *contentsource `json:"source,omitempty"` // image or document content + CacheControl *cachecontrol `json:"cache_control,omitempty"` // ephemeral } -type Text struct { - Type string `json:"type"` // text - Text string `json:"text"` +type contentsource struct { + Type string `json:"type"` // base64 or text + MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp, application/pdf, text/plain + Data any `json:"data"` // ...base64 or text encoded data +} + +type cachecontrol struct { + Type string `json:"type"` // ephemeral +} + +type contentcitation struct { + Enabled bool `json:"enabled"` // true } /////////////////////////////////////////////////////////////////////////////// @@ -68,19 +79,20 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { context := &message{} context.MessageMeta.Role = "user" - if len(opt.data) > 0 { - content := make([]any, 0, len(opt.data)+1) - content = append(content, &Text{ - Type: "text", - Text: text, - }) - for _, data := range opt.data { - content = append(content, data) - } - context.MessageMeta.Content = content - } else { - context.MessageMeta.Content = text + context.MessageMeta.Content = make([]*Content, 0, len(opt.data)+1) + + // Append the text + context.MessageMeta.Content = append(context.MessageMeta.Content, &Content{ + Type: "text", + Text: text, + }) + + // Append any additional data + for _, data := range opt.data { + context.MessageMeta.Content = append(context.MessageMeta.Content, data) } + + // Return the context return context } @@ -99,11 +111,12 @@ var ( "image/gif": "image", "image/webp": "image", "application/pdf": "document", + "text/plain": "text", } ) -// Create a new attachment from an io.Reader -func NewAttachment(r io.Reader) (*Attachment, error) { +// Read content from an io.Reader +func ReadContent(r io.Reader, ephemeral, citations bool) (*Content, error) { var data bytes.Buffer if _, err := io.Copy(&data, r); err != nil { return nil, err @@ -111,18 +124,67 @@ func NewAttachment(r io.Reader) (*Attachment, error) { // Detect mimetype mimetype := http.DetectContentType(data.Bytes()) + if strings.HasPrefix(mimetype, "text/") { + // Switch to text/plain - TODO: charsets? + mimetype = "text/plain" + } + + // Check supported mimetype typ, exists := supportedAttachments[mimetype] if !exists { return nil, llm.ErrBadParameter.Withf("unsupported or undetected mimetype %q", mimetype) } // Create attachment - attachment := &Attachment{ - Type: typ, + attachment := new(Content) + attachment.Type = typ + if ephemeral { + attachment.CacheControl = &cachecontrol{Type: "ephemeral"} + } + + // Handle by type + switch typ { + case "text": + attachment.Type = "document" + attachment.Source = &contentsource{ + Type: "text", + MediaType: mimetype, + Data: data.String(), + } + + // Check for filename + if f, ok := r.(*os.File); ok && f.Name() != "" { + attachment.Title = f.Name() + } + + // Check for citations + if citations { + attachment.Citations = &contentcitation{Enabled: true} + } + case "document": + // Check for filename + if f, ok := r.(*os.File); ok && f.Name() != "" { + attachment.Title = f.Name() + } + + // Check for citations + if citations { + attachment.Citations = &contentcitation{Enabled: true} + } + attachment.Source = &contentsource{ + Type: "base64", + MediaType: mimetype, + Data: data.Bytes(), + } + case "image": + attachment.Source = &contentsource{ + Type: "base64", + MediaType: mimetype, + Data: data.Bytes(), + } + default: + return nil, llm.ErrBadParameter.Withf("unsupported attachment type %q", typ) } - attachment.Source.Type = "base64" - attachment.Source.MediaType = mimetype - attachment.Source.Data = data.Bytes() // Return success return attachment, nil diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index fd2b181..994b580 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -33,7 +33,35 @@ func Test_messages_001(t *testing.T) { } defer f.Close() - response, err := client.Messages(context.TODO(), model, client.UserPrompt("what is this image?", anthropic.WithData(f))) + response, err := client.Messages(context.TODO(), model, client.UserPrompt("what is this image?", anthropic.WithData(f, false, false))) + if assert.NoError(err) { + t.Log(response) + } +} + +func Test_messages_002(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } else { + t.FailNow() + } + + f, err := os.Open("testdata/LICENSE") + if !assert.NoError(err) { + t.FailNow() + } + defer f.Close() + + response, err := client.Messages(context.TODO(), model, client.UserPrompt("summarize this document for me", anthropic.WithData(f, false, false))) if assert.NoError(err) { t.Log(response) } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 283a21f..cf2ae91 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -20,8 +20,8 @@ type opt struct { TopK uint `json:"top_k,omitempty"` TopP float64 `json:"top_p,omitempty"` - // Attachments for messages - data []*Attachment + // Additional message content + data []*Content } type optmetadata struct { @@ -44,9 +44,9 @@ func apply(opts ...llm.Opt) (*opt, error) { //////////////////////////////////////////////////////////////////////////////// // OPTIONS -func WithData(r io.Reader) llm.Opt { +func WithData(r io.Reader, ephemeral, citations bool) llm.Opt { return func(o any) error { - attachment, err := NewAttachment(r) + attachment, err := ReadContent(r, ephemeral, citations) if err != nil { return err } diff --git a/pkg/anthropic/testdata/LICENSE b/pkg/anthropic/testdata/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/pkg/anthropic/testdata/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From 986f801f9a3ca2a95abcdbc2e34566cacd7b3fb0 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 13:09:46 +0100 Subject: [PATCH 08/33] Updated --- pkg/anthropic/client.go | 7 +------ pkg/anthropic/messages.go | 5 +++++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/anthropic/client.go b/pkg/anthropic/client.go index 6e78a72..2c9292d 100644 --- a/pkg/anthropic/client.go +++ b/pkg/anthropic/client.go @@ -54,12 +54,7 @@ func (*Client) Name() string { return defaultName } -// Generate a response from a prompt -func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { - return nil, llm.ErrNotImplemented -} - -// Embedding vector generation +// Embedding vector generation - not supported on Anthropic func (*Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { return nil, llm.ErrNotImplemented } diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 31ab435..457f95b 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -95,6 +95,11 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context return &response, nil } +// Generate a response from a prompt +func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { + return nil, llm.ErrNotImplemented +} + /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS From fce804e72b45d56daea5f17f35d040963510a209 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 15:45:52 +0100 Subject: [PATCH 09/33] Updated messages for streaming --- pkg/anthropic/messages.go | 95 ++++++++++++++++++++++++++++++++-- pkg/anthropic/messages_test.go | 22 ++++++++ pkg/anthropic/opt.go | 10 +++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 457f95b..0620662 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -3,6 +3,7 @@ package anthropic import ( "context" "encoding/json" + "fmt" "strings" // Packages @@ -43,13 +44,21 @@ func (r Response) String() string { return string(data) } +func (r opt) String() string { + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS type reqMessages struct { Model string `json:"model"` Messages []*MessageMeta `json:"messages"` - *opt + opt } func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (*Response, error) { @@ -59,6 +68,8 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context return nil, err } + fmt.Println(opt) + // Context to append to the request messages := []*MessageMeta{} message, ok := context.(*message) @@ -79,15 +90,91 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context req, err := client.NewJSONRequest(reqMessages{ Model: model.Name(), Messages: messages, - opt: opt, + opt: *opt, }) if err != nil { return nil, err } - // Response + // Stream var response Response - if err := anthropic.DoWithContext(ctx, req, &response, client.OptPath("messages")); err != nil { + reqopts := []client.RequestOpt{ + client.OptPath("messages"), + } + if opt.Stream { + reqopts = append(reqopts, client.OptTextStreamCallback(func(evt client.TextStreamEvent) error { + switch evt.Event { + case "message_start": + // Start of a message + var r struct { + Type string `json:"type"` + Response Response `json:"message"` + } + if err := evt.Json(&r); err != nil { + return err + } else { + response = r.Response + } + case "content_block_start": + // Start of a content block, append to response + var r struct { + Type string `json:"type"` + Index uint `json:"index"` + Content Content `json:"content_block"` + } + if err := evt.Json(&r); err != nil { + return err + } else { + fmt.Println("content_block_start", r) + } + case "content_block_delta": + // Continuation of a content block, append to content + var r struct { + Type string `json:"type"` + Index uint `json:"index"` + Content Content `json:"delta"` + } + if err := evt.Json(&r); err != nil { + return err + } else { + fmt.Println("content_block_delta", r) + } + case "content_block_stop": + // End of a content block + var r struct { + Type string `json:"type"` + Index uint `json:"index"` + } + if err := evt.Json(&r); err != nil { + return err + } else { + fmt.Println("content_block_stop", r) + } + case "message_delta": + // Message update + var r struct { + Type string `json:"type"` + Delta Response `json:"index"` + Usage Metrics `json:"usage"` + } + if err := evt.Json(&r); err != nil { + return err + } else { + fmt.Println("message_delta", r) + } + case "message_stop": + // NO-OP + case "ping": + // NO-OP + default: + // NO-OP + } + return nil + })) + } + + // Response + if err := anthropic.DoWithContext(ctx, req, &response, reqopts...); err != nil { return nil, err } diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 994b580..2913fdb 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -66,3 +66,25 @@ func Test_messages_002(t *testing.T) { t.Log(response) } } + +func Test_messages_003(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } else { + t.FailNow() + } + + response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream()) + if assert.NoError(err) { + t.Log(response) + } +} diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index cf2ae91..346ac67 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -14,7 +14,7 @@ type opt struct { MaxTokens uint `json:"max_tokens,omitempty"` Metadata *optmetadata `json:"metadata,omitempty"` StopSequences []string `json:"stop_sequences,omitempty"` - Stream *bool `json:"stream,omitempty"` + Stream bool `json:"stream,omitempty"` System string `json:"system,omitempty"` Temperature float64 `json:"temperature,omitempty"` TopK uint `json:"top_k,omitempty"` @@ -44,6 +44,14 @@ func apply(opts ...llm.Opt) (*opt, error) { //////////////////////////////////////////////////////////////////////////////// // OPTIONS +// Messages: Stream the response as it is received. +func WithStream() llm.Opt { + return func(o any) error { + o.(*opt).Stream = true + return nil + } +} + func WithData(r io.Reader, ephemeral, citations bool) llm.Opt { return func(o any) error { attachment, err := ReadContent(r, ephemeral, citations) From 751bc30ea6840594ff121cf30621af473a5b210a Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 18:04:35 +0100 Subject: [PATCH 10/33] Updated anthropic --- pkg/anthropic/message.go | 54 ++++++++-- pkg/anthropic/messages.go | 69 +++++++++++-- pkg/anthropic/messages_test.go | 64 +++++++++++- pkg/anthropic/opt.go | 20 +++- pkg/anthropic/tool.go | 177 +++++++++++++++++++++++++++++++++ 5 files changed, 360 insertions(+), 24 deletions(-) create mode 100644 pkg/anthropic/tool.go diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index e5041c5..0ca47b9 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -28,13 +28,29 @@ type MessageMeta struct { } type Content struct { - Type string `json:"type"` // image, document, text - Text string `json:"text,omitempty"` // text content - Title string `json:"title,omitempty"` // title of the document - Context string `json:"context,omitempty"` // context of the document - Citations *contentcitation `json:"citations,omitempty"` // citations of the document - Source *contentsource `json:"source,omitempty"` // image or document content - CacheControl *cachecontrol `json:"cache_control,omitempty"` // ephemeral + Type string `json:"type"` // image, document, text, tool_use + ContextText + ContextAttachment + ContextTool + CacheControl *cachecontrol `json:"cache_control,omitempty"` // ephemeral +} + +type ContextText struct { + Text string `json:"text,omitempty"` // text content +} + +type ContextTool struct { + Id string `json:"id,omitempty"` // tool id + Name string `json:"name,omitempty"` // tool name + Input map[string]any `json:"input,omitempty"` // tool input + InputJson string `json:"partial_json,omitempty"` // partial json input (for streaming) +} + +type ContextAttachment struct { + Title string `json:"title,omitempty"` // title of the document + Context string `json:"context,omitempty"` // context of the document + Citations *contentcitation `json:"citations,omitempty"` // citations of the document + Source *contentsource `json:"source,omitempty"` // image or document content } type contentsource struct { @@ -51,6 +67,17 @@ type contentcitation struct { Enabled bool `json:"enabled"` // true } +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Return a Content object with text content +func NewTextContent(v string) *Content { + content := new(Content) + content.Type = "text" + content.ContextText.Text = v + return content +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY @@ -62,6 +89,14 @@ func (m message) String() string { return string(data) } +func (m MessageMeta) String() string { + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -82,10 +117,7 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { context.MessageMeta.Content = make([]*Content, 0, len(opt.data)+1) // Append the text - context.MessageMeta.Content = append(context.MessageMeta.Content, &Content{ - Type: "text", - Text: text, - }) + context.MessageMeta.Content = append(context.MessageMeta.Content, NewTextContent(text)) // Append any additional data for _, data := range opt.data { diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 0620662..2927bac 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -68,8 +68,6 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context return nil, err } - fmt.Println(opt) - // Context to append to the request messages := []*MessageMeta{} message, ok := context.(*message) @@ -102,6 +100,28 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context client.OptPath("messages"), } if opt.Stream { + // Append delta to content + appendDelta := func(content []*Content, delta *Content) ([]*Content, error) { + if len(content) == 0 { + return nil, fmt.Errorf("unexpected delta") + } + + // Get the content block we want to append to + last := content[len(content)-1] + + // Append text_delta + switch { + case last.Type == "text" && delta.Type == "text_delta": + last.Text += delta.Text + case last.Type == "tool_use" && delta.Type == "input_json_delta": + last.InputJson += delta.InputJson + default: + return nil, fmt.Errorf("unexpected delta %s for %s", delta.Type, last.Type) + } + + // Return the content + return content, nil + } reqopts = append(reqopts, client.OptTextStreamCallback(func(evt client.TextStreamEvent) error { switch evt.Event { case "message_start": @@ -124,8 +144,10 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context } if err := evt.Json(&r); err != nil { return err + } else if int(r.Index) != len(response.MessageMeta.Content) { + return fmt.Errorf("%s: unexpected index %d", r.Type, r.Index) } else { - fmt.Println("content_block_start", r) + response.MessageMeta.Content = append(response.MessageMeta.Content, &r.Content) } case "content_block_delta": // Continuation of a content block, append to content @@ -136,8 +158,13 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context } if err := evt.Json(&r); err != nil { return err + } else if int(r.Index) != len(response.MessageMeta.Content)-1 { + fmt.Println(response.MessageMeta) + return fmt.Errorf("%s: unexpected index %d", r.Type, r.Index) + } else if content, err := appendDelta(response.MessageMeta.Content, &r.Content); err != nil { + return err } else { - fmt.Println("content_block_delta", r) + response.MessageMeta.Content = content } case "content_block_stop": // End of a content block @@ -147,28 +174,52 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context } if err := evt.Json(&r); err != nil { return err - } else { - fmt.Println("content_block_stop", r) + } else if int(r.Index) != len(response.MessageMeta.Content)-1 { + return fmt.Errorf("%s: unexpected index %d", r.Type, r.Index) + } + // We need to convert the partial_json response into a full json object + content := response.MessageMeta.Content[r.Index] + if content.Type == "tool_use" && content.InputJson != "" { + if err := json.Unmarshal([]byte(content.InputJson), &content.Input); err != nil { + return err + } } case "message_delta": // Message update var r struct { Type string `json:"type"` - Delta Response `json:"index"` + Delta Response `json:"delta"` Usage Metrics `json:"usage"` } if err := evt.Json(&r); err != nil { return err - } else { - fmt.Println("message_delta", r) } + + // Update stop reason + response.Reason = r.Delta.Reason + response.StopSequence = r.Delta.StopSequence + + // Update metrics + response.Metrics.InputTokens += r.Usage.InputTokens + response.Metrics.OutputTokens += r.Usage.OutputTokens + response.Metrics.CacheCreationInputTokens += r.Usage.CacheCreationInputTokens + response.Metrics.CacheReadInputTokens += r.Usage.CacheReadInputTokens case "message_stop": // NO-OP + return nil case "ping": // NO-OP + return nil default: // NO-OP + return nil + } + + if opt.callback != nil { + opt.callback(&response) } + + // Return success return nil })) } diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 2913fdb..ae19b32 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -83,7 +83,69 @@ func Test_messages_003(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream()) + response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + t.Log(r) + })) + if assert.NoError(err) { + t.Log(response) + } +} + +func Test_messages_004(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } else { + t.FailNow() + } + + weather, err := anthropic.NewTool("weather_in_location", "Get the weather in a location", struct { + Location string `name:"location" help:"The location to get the weather for" required:"true"` + }{}) + if !assert.NoError(err) { + t.FailNow() + } + + response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithTool(weather)) + if assert.NoError(err) { + t.Log(response) + } +} + +func Test_messages_005(t *testing.T) { + assert := assert.New(t) + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if assert.NoError(err) { + assert.NotNil(client) + t.Log(client) + } else { + t.FailNow() + } + + weather, err := anthropic.NewTool("weather_in_location", "Get the weather in a location", struct { + Location string `name:"location" help:"The location to get the weather for" required:"true"` + }{}) + if !assert.NoError(err) { + t.FailNow() + } + + response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + t.Log(r) + }), anthropic.WithTool(weather)) if assert.NoError(err) { t.Log(response) } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 346ac67..77d1d66 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -19,9 +19,10 @@ type opt struct { Temperature float64 `json:"temperature,omitempty"` TopK uint `json:"top_k,omitempty"` TopP float64 `json:"top_p,omitempty"` + Tools []*Tool `json:"tools,omitempty"` - // Additional message content - data []*Content + data []*Content // Additional message content + callback func(*Response) // Streaming callback } type optmetadata struct { @@ -45,13 +46,16 @@ func apply(opts ...llm.Opt) (*opt, error) { // OPTIONS // Messages: Stream the response as it is received. -func WithStream() llm.Opt { +func WithStream(fn func(*Response)) llm.Opt { return func(o any) error { o.(*opt).Stream = true + o.(*opt).callback = fn return nil } } +// Messages: Attach data to the request, which can be cached on the server-side +// and cited the response. func WithData(r io.Reader, ephemeral, citations bool) llm.Opt { return func(o any) error { attachment, err := ReadContent(r, ephemeral, citations) @@ -117,3 +121,13 @@ func WithTopK(v uint) llm.Opt { return nil } } + +// Messages: Append a tool to the request. +func WithTool(v *Tool) llm.Opt { + return func(o any) error { + if v != nil { + o.(*opt).Tools = append(o.(*opt).Tools, v) + } + return nil + } +} diff --git a/pkg/anthropic/tool.go b/pkg/anthropic/tool.go new file mode 100644 index 0000000..a343aa5 --- /dev/null +++ b/pkg/anthropic/tool.go @@ -0,0 +1,177 @@ +package anthropic + +import ( + "encoding/json" + "reflect" + "strings" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Tool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters struct { + Type string `json:"type,omitempty"` + Required []string `json:"required,omitempty"` + Properties map[string]ToolParameter `json:"properties,omitempty"` + } `json:"input_schema"` + proto reflect.Type // Prototype for parameter return +} + +type ToolParameter struct { + Name string `json:"-"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + required bool + index []int // Field index into prototype for setting a field +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Return a tool, or panic if there is an error +func MustTool(name, description string, params any) *Tool { + tool, err := NewTool(name, description, params) + if err != nil { + panic(err) + } + return tool +} + +// Return a new tool definition +func NewTool(name, description string, params any) (*Tool, error) { + tool := Tool{ + Name: name, + Description: description, + proto: reflect.TypeOf(params), + } + + // Add parameters + tool.Parameters.Type = "object" + toolparams, err := paramsFor(params) + if err != nil { + return nil, err + } + + // Set parameters + tool.Parameters.Required = make([]string, 0, len(toolparams)) + tool.Parameters.Properties = make(map[string]ToolParameter, len(toolparams)) + for _, param := range toolparams { + if _, exists := tool.Parameters.Properties[param.Name]; exists { + return nil, llm.ErrConflict.Withf("parameter %q already exists", param.Name) + } else { + tool.Parameters.Properties[param.Name] = param + } + if param.required { + tool.Parameters.Required = append(tool.Parameters.Required, param.Name) + } + } + + // Return success + return &tool, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (t Tool) String() string { + data, err := json.MarshalIndent(t, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +// Return tool parameters from a struct +func paramsFor(params any) ([]ToolParameter, error) { + rt := reflect.TypeOf(params) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return nil, llm.ErrBadParameter.With("params must be a struct") + } + + // Iterate over fields + fields := reflect.VisibleFields(rt) + result := make([]ToolParameter, 0, len(fields)) + for _, field := range fields { + if param, err := paramFor(field); err != nil { + return nil, err + } else { + result = append(result, param) + } + } + + // Return success + return result, nil +} + +// Return tool parameters from a struct field +func paramFor(field reflect.StructField) (ToolParameter, error) { + // Name + name := field.Tag.Get("name") + if name == "" { + name = field.Name + } + + // Type + typ, err := paramType(field) + if err != nil { + return ToolParameter{}, err + } + + // Required + _, required := field.Tag.Lookup("required") + + // Enum + enum := []string{} + if enum_ := field.Tag.Get("enum"); enum_ != "" { + enum = strings.Split(enum_, ",") + } + + // Return success + return ToolParameter{ + Name: field.Name, + Type: typ, + Description: field.Tag.Get("help"), + Enum: enum, + required: required, + index: field.Index, + }, nil +} + +var ( + typeString = reflect.TypeOf("") + typeUint = reflect.TypeOf(uint(0)) + typeInt = reflect.TypeOf(int(0)) + typeFloat64 = reflect.TypeOf(float64(0)) + typeFloat32 = reflect.TypeOf(float32(0)) +) + +// Return parameter type from a struct field +func paramType(field reflect.StructField) (string, error) { + t := field.Type + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + switch field.Type { + case typeString: + return "string", nil + case typeUint, typeInt: + return "integer", nil + case typeFloat64, typeFloat32: + return "number", nil + default: + return "", llm.ErrBadParameter.Withf("unsupported type %v for field %q", field.Type, field.Name) + } +} From 0609803719fbe30e497fdfaca2ccb122eec54389 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 19:46:34 +0100 Subject: [PATCH 11/33] Added agent --- agent.go | 4 +- cmd/agent/main.go | 2 +- cmd/agent/models.go | 28 ++++++++++++-- pkg/agent/agent.go | 79 ++++++++++++++++++++++++++++++--------- pkg/agent/opt.go | 15 ++++++++ pkg/anthropic/message.go | 31 ++++++++++----- pkg/anthropic/messages.go | 2 +- pkg/ollama/client.go | 2 +- pkg/ollama/message.go | 21 ++--------- tool.go | 79 ++------------------------------------- 10 files changed, 134 insertions(+), 129 deletions(-) diff --git a/agent.go b/agent.go index b97ee43..76bc6c5 100644 --- a/agent.go +++ b/agent.go @@ -13,7 +13,7 @@ type Agent interface { Models(context.Context) ([]Model, error) // Generate a response from a prompt - Generate(context.Context, Model, Context, ...Opt) (*Response, error) + Generate(context.Context, Model, Context, ...Opt) (Context, error) // Embedding vector generation Embedding(context.Context, Model, string, ...Opt) ([]float64, error) @@ -22,5 +22,5 @@ type Agent interface { UserPrompt(string, ...Opt) Context // Create the result of calling a tool - ToolResult(any) Context + ToolResult(id string, opts ...Opt) Context } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index f1d8b96..badc5c2 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -43,7 +43,7 @@ type CLI struct { Globals // Agents, Models and Tools - //Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` + Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` Models ListModelsCmd `cmd:"" help:"Return a list of models"` } diff --git a/cmd/agent/models.go b/cmd/agent/models.go index 374803c..b710159 100644 --- a/cmd/agent/models.go +++ b/cmd/agent/models.go @@ -4,21 +4,30 @@ import ( "context" "fmt" + // Packages llm "github.com/mutablelogic/go-llm" + agent "github.com/mutablelogic/go-llm/pkg/agent" ) //////////////////////////////////////////////////////////////////////////////// // TYPES type ListModelsCmd struct { + Agent []string `help:"Only return models from a specific agent"` } +type ListAgentsCmd struct{} + //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (*ListModelsCmd) Run(globals *Globals) error { - return runagent(globals, func(ctx context.Context, agent llm.Agent) error { - models, err := agent.Models(ctx) +func (cmd *ListModelsCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, client llm.Agent) error { + agent, ok := client.(*agent.Agent) + if !ok { + return fmt.Errorf("No agents found") + } + models, err := agent.ListModels(ctx, cmd.Agent...) if err != nil { return err } @@ -27,6 +36,19 @@ func (*ListModelsCmd) Run(globals *Globals) error { }) } +func (*ListAgentsCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, client llm.Agent) error { + agent, ok := client.(*agent.Agent) + if !ok { + return fmt.Errorf("No agents found") + } + for _, agent := range agent.Agents() { + fmt.Println(agent) + } + return nil + }) +} + //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index a87a073..62b3fc6 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "slices" "strings" @@ -14,7 +15,7 @@ import ( /////////////////////////////////////////////////////////////////////////////// // TYPES -type agent struct { +type Agent struct { *opt } @@ -23,14 +24,14 @@ type model struct { llm.Model `json:"model"` } -var _ llm.Agent = (*agent)(nil) +var _ llm.Agent = (*Agent)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// Return a new agent, composed of several different models from different providers -func New(opts ...llm.Opt) (*agent, error) { - agent := new(agent) +// Return a new agent, composed of agents and tools +func New(opts ...llm.Opt) (*Agent, error) { + agent := new(Agent) opt, err := apply(opts...) if err != nil { return nil, err @@ -57,7 +58,7 @@ func (m model) String() string { // PUBLIC METHODS // Return a list of agent names -func (a *agent) Agents() []string { +func (a *Agent) Agents() []string { var keys []string for k := range a.agents { keys = append(keys, k) @@ -65,17 +66,42 @@ func (a *agent) Agents() []string { return keys } +// Return a list of tool names +func (a *Agent) Tools() []string { + var keys []string + for k := range a.tools { + keys = append(keys, k) + } + return keys +} + // Return a comma-separated list of agent names -func (a *agent) Name() string { +func (a *Agent) Name() string { return strings.Join(a.Agents(), ",") } // Return the models from all agents -func (a *agent) Models(ctx context.Context) ([]llm.Model, error) { +func (a *Agent) Models(ctx context.Context) ([]llm.Model, error) { + return a.ListModels(ctx) +} + +// Return the models from list of agents +func (a *Agent) ListModels(ctx context.Context, agents ...string) ([]llm.Model, error) { var result error + // Ensure all agents are valid + for _, agent := range agents { + if _, exists := a.agents[agent]; !exists { + result = errors.Join(result, llm.ErrNotFound.Withf("agent %q", agent)) + } + } + + // Gather models from all agents models := make([]llm.Model, 0, 100) for _, agent := range a.agents { + if len(agents) > 0 && !slices.Contains(agents, agent.Name()) { + continue + } agentmodels, err := modelsForAgent(ctx, agent) if err != nil { result = errors.Join(result, err) @@ -91,19 +117,27 @@ func (a *agent) Models(ctx context.Context) ([]llm.Model, error) { // Return a model by name. If no agents are specified, then all agents are considered. // If multiple agents are specified, then the first model found is returned. -func (a *agent) Model(ctx context.Context, name string, agent ...string) (llm.Model, error) { - if len(agent) == 0 { - agent = a.Agents() +func (a *Agent) GetModel(ctx context.Context, name string, agents ...string) (llm.Model, error) { + if len(agents) == 0 { + agents = a.Agents() } + // Ensure all agents are valid var result error - for _, agent := range agent { + for _, agent := range agents { + if _, exists := a.agents[agent]; !exists { + result = errors.Join(result, llm.ErrNotFound.Withf("agent %q", agent)) + } + } + + // Gather models from agents + for _, agent := range agents { models, err := modelsForAgent(ctx, a.agents[agent], name) if err != nil { result = errors.Join(result, err) continue } else if len(models) > 0 { - return models[0], nil + return models[0], result } } @@ -112,22 +146,33 @@ func (a *agent) Model(ctx context.Context, name string, agent ...string) (llm.Mo } // Generate a response from a prompt -func (a *agent) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { +func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { + // Obtain the agent + var agent llm.Agent + if model, ok := m.(*model); !ok || model == nil { + return nil, llm.ErrBadParameter.With("model") + } else if agent_, exists := a.agents[model.Agent]; !exists { + return nil, llm.ErrNotFound.Withf("agent %q", model.Agent) + } else { + agent = agent_ + } + fmt.Println(agent) + return nil, llm.ErrNotImplemented } // Embedding vector generation -func (a *agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { +func (a *Agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { return nil, llm.ErrNotImplemented } // Create user message context -func (a *agent) UserPrompt(string, ...llm.Opt) llm.Context { +func (a *Agent) UserPrompt(string, ...llm.Opt) llm.Context { return nil } // Create the result of calling a tool -func (a *agent) ToolResult(any) llm.Context { +func (a *Agent) ToolResult(id string, opts ...llm.Opt) llm.Context { return nil } diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index b1a9c2a..804bd9f 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -13,6 +13,7 @@ import ( type opt struct { agents map[string]llm.Agent + tools map[string]llm.Tool } //////////////////////////////////////////////////////////////////////////////// @@ -54,6 +55,20 @@ func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { } } +func WithTools(tools ...llm.Tool) llm.Opt { + return func(o any) error { + for _, tool := range tools { + name := tool.Name() + if _, exists := o.(*opt).tools[name]; exists { + return llm.ErrConflict.Withf("Tool %q already exists", name) + } + o.(*opt).tools[name] = tool + } + // Return success + return nil + } +} + //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 0ca47b9..51d24c6 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -29,30 +29,36 @@ type MessageMeta struct { type Content struct { Type string `json:"type"` // image, document, text, tool_use - ContextText - ContextAttachment - ContextTool + ContentText + ContentAttachment + ContentTool + ContentToolResult CacheControl *cachecontrol `json:"cache_control,omitempty"` // ephemeral } -type ContextText struct { +type ContentText struct { Text string `json:"text,omitempty"` // text content } -type ContextTool struct { +type ContentTool struct { Id string `json:"id,omitempty"` // tool id Name string `json:"name,omitempty"` // tool name Input map[string]any `json:"input,omitempty"` // tool input InputJson string `json:"partial_json,omitempty"` // partial json input (for streaming) } -type ContextAttachment struct { +type ContentAttachment struct { Title string `json:"title,omitempty"` // title of the document Context string `json:"context,omitempty"` // context of the document Citations *contentcitation `json:"citations,omitempty"` // citations of the document Source *contentsource `json:"source,omitempty"` // image or document content } +type ContentToolResult struct { + Id string `json:"tool_use_id,omitempty"` // tool id + Content []*Content `json:"content,omitempty"` +} + type contentsource struct { Type string `json:"type"` // base64 or text MediaType string `json:"media_type"` // image/jpeg, image/png, image/gif, image/webp, application/pdf, text/plain @@ -74,7 +80,7 @@ type contentcitation struct { func NewTextContent(v string) *Content { content := new(Content) content.Type = "text" - content.ContextText.Text = v + content.ContentText.Text = v return content } @@ -112,7 +118,7 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { return nil } - context := &message{} + context := new(message) context.MessageMeta.Role = "user" context.MessageMeta.Content = make([]*Content, 0, len(opt.data)+1) @@ -129,8 +135,13 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { } // Create the result of calling a tool -func (*Client) ToolResult(any) llm.Context { - return nil +func (*Client) ToolResult(id string, opts ...llm.Opt) llm.Context { + context := new(message) + context.MessageMeta.Role = "user" + context.MessageMeta.Content = make([]*Content, 0, 1) + + // Return the context + return context } /////////////////////////////////////////////////////////////////////////////// diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 2927bac..cb74946 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -234,7 +234,7 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context } // Generate a response from a prompt -func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (*llm.Response, error) { +func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (llm.Context, error) { return nil, llm.ErrNotImplemented } diff --git a/pkg/ollama/client.go b/pkg/ollama/client.go index 2a399d6..9ad28ca 100644 --- a/pkg/ollama/client.go +++ b/pkg/ollama/client.go @@ -50,6 +50,6 @@ func (*Client) Name() string { } // Generate a response from a prompt -func (ollama *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (*llm.Response, error) { +func (ollama *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { return nil, llm.ErrNotImplemented } diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index fd1e662..d2c7a3c 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -63,24 +63,9 @@ func (ollama *Client) UserPrompt(v string, opts ...llm.Opt) llm.Context { } // The result of a tool call -func (ollama *Client) ToolResult(v any) llm.Context { - m := new(message) - m.MessageMeta.Role = "tool" - - switch v := v.(type) { - case string: - m.MessageMeta.Content = v - default: - // Encode the result into json - data, err := json.Marshal(v) - if err != nil { - return nil - } - m.MessageMeta.Content = string(data) - } - - // Return success - return m +func (ollama *Client) ToolResult(id string, opts ...llm.Opt) llm.Context { + // messages.append({'role': 'tool', 'content': str(output), 'name': tool.function.name}) + return nil } // Return the role of a message diff --git a/tool.go b/tool.go index 53e9e4c..a61b0d5 100644 --- a/tool.go +++ b/tool.go @@ -2,9 +2,6 @@ package llm import ( "context" - "encoding/json" - "fmt" - "strconv" ) //////////////////////////////////////////////////////////////////////////////// @@ -12,9 +9,6 @@ import ( // A tool can be called from an LLM type Tool interface { - // Return the provider of the tool - Provider() string - // Return the name of the tool Name() string @@ -22,75 +16,8 @@ type Tool interface { Description() string // Tool parameters - Params() []ToolParameter - - // Execute the tool with a specific tool - Run(context.Context, *ToolCall) (*ToolResult, error) -} - -// A tool parameter -type ToolParameter struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - Required bool `json:"required,omitempty"` -} - -// A call to a tool -type ToolCall struct { - Id string `json:"id"` - Name string `json:"name"` - Args map[string]any `json:"args"` -} - -// The result of a tool call -type ToolResult struct { - Id string `json:"id"` - Result map[string]any `json:"result,omitempty"` -} - -//////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// Return the arguments for the call as a JSON -func (t *ToolCall) Json() string { - data, err := json.MarshalIndent(t.Args, "", " ") - if err != nil { - return err.Error() - } else { - return string(data) - } -} - -// Return role for the tool result -func (t *ToolResult) Role() string { - return "tool" -} - -// Return parameter as a string -func (t *ToolCall) String(name string) (string, error) { - v, ok := t.Args[name] - if !ok { - return "", ErrNotFound.Withf("%q not found", name) - } - return fmt.Sprint(v), nil -} + Params() any -// Return parameter as an integer -func (t *ToolCall) Int(name string) (int, error) { - v, ok := t.Args[name] - if !ok { - return 0, ErrNotFound.Withf("%q not found", name) - } - switch v := v.(type) { - case int: - return v, nil - case string: - if v_, err := strconv.ParseInt(v, 10, 32); err != nil { - return 0, ErrBadParameter.Withf("%q: %v", name, err) - } else { - return int(v_), nil - } - default: - return 0, ErrBadParameter.Withf("%q: Expected integer, got %T", name, v) - } + // Execute the tool with parameters + Run(context.Context, any) error } From 78932af9fc97a45ca1b46baea9835df695243fa7 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Wed, 29 Jan 2025 19:58:19 +0100 Subject: [PATCH 12/33] Added generate in the agent --- cmd/agent/generate.go | 46 +++++++++++++++++++++++++++++++++++++++ cmd/agent/main.go | 5 +++-- pkg/agent/agent.go | 20 +++-------------- pkg/agent/generate.go | 29 ++++++++++++++++++++++++ pkg/anthropic/messages.go | 7 +++++- 5 files changed, 87 insertions(+), 20 deletions(-) create mode 100644 cmd/agent/generate.go create mode 100644 pkg/agent/generate.go diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go new file mode 100644 index 0000000..25bd584 --- /dev/null +++ b/cmd/agent/generate.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + + // Packages + llm "github.com/mutablelogic/go-llm" + agent "github.com/mutablelogic/go-llm/pkg/agent" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +type GenerateCmd struct { + Model string `arg:"" help:"Model name"` + Text string `arg:"" help:"Text to generate a response for"` +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (cmd *GenerateCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, client llm.Agent) error { + // Get the model + // TODO: Model should be cached + agent, ok := client.(*agent.Agent) + if !ok { + return fmt.Errorf("No agents found") + } + model, err := agent.GetModel(ctx, cmd.Model) + if err != nil { + return err + } + + // Generate the content + response, err := agent.Generate(ctx, model, agent.UserPrompt(cmd.Text)) + if err != nil { + return err + } + + // Print the response + fmt.Println("RESPONSE=", response) + return nil + }) +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go index badc5c2..e8745bd 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -43,8 +43,9 @@ type CLI struct { Globals // Agents, Models and Tools - Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` - Models ListModelsCmd `cmd:"" help:"Return a list of models"` + Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` + Models ListModelsCmd `cmd:"" help:"Return a list of models"` + Generate GenerateCmd `cmd:"" help:"Generate a response"` } //////////////////////////////////////////////////////////////////////////////// diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 62b3fc6..de701a5 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "slices" "strings" @@ -141,26 +140,13 @@ func (a *Agent) GetModel(ctx context.Context, name string, agents ...string) (ll } } + // Return not found + result = errors.Join(result, llm.ErrNotFound.Withf("model %q", name)) + // Return any errors return nil, result } -// Generate a response from a prompt -func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { - // Obtain the agent - var agent llm.Agent - if model, ok := m.(*model); !ok || model == nil { - return nil, llm.ErrBadParameter.With("model") - } else if agent_, exists := a.agents[model.Agent]; !exists { - return nil, llm.ErrNotFound.Withf("agent %q", model.Agent) - } else { - agent = agent_ - } - fmt.Println(agent) - - return nil, llm.ErrNotImplemented -} - // Embedding vector generation func (a *Agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { return nil, llm.ErrNotImplemented diff --git a/pkg/agent/generate.go b/pkg/agent/generate.go new file mode 100644 index 0000000..d2e8445 --- /dev/null +++ b/pkg/agent/generate.go @@ -0,0 +1,29 @@ +package agent + +import ( + "context" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Generate a response from a prompt +func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { + // Obtain the agent + var agent llm.Agent + if model, ok := m.(*model); !ok || model == nil { + return nil, llm.ErrBadParameter.With("model") + } else if agent_, exists := a.agents[model.Agent]; !exists { + return nil, llm.ErrNotFound.Withf("agent %q", model.Agent) + } else { + agent = agent_ + } + + // TODO: Translate all the opts + + // Call Generate for the agent + return agent.Generate(ctx, m, context, opts...) +} diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index cb74946..1e6ce4a 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -234,7 +234,12 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context } // Generate a response from a prompt -func (*Client) Generate(context.Context, llm.Model, llm.Context, ...llm.Opt) (llm.Context, error) { +func (anthropic *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { + response, err := anthropic.Messages(ctx, model, context, opts...) + if err != nil { + return nil, err + } + fmt.Println(response) return nil, llm.ErrNotImplemented } From 7641966ee25f06591c5d0e0e51519a5466d6321e Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 08:24:29 +0100 Subject: [PATCH 13/33] Updated LLM --- agent.go | 6 ----- cmd/agent/generate.go | 2 +- go.mod | 1 + go.sum | 8 ++++++ model.go | 6 +++++ pkg/agent/agent.go | 5 ---- pkg/agent/context.go | 6 +++++ pkg/agent/generate.go | 9 ++++++- pkg/agent/opt.go | 34 ++++++++++++++++++++++++++ pkg/anthropic/message.go | 4 +-- pkg/ollama/chat.go | 24 ++++++------------ pkg/ollama/client.go | 6 ----- pkg/ollama/generate.go | 25 +++++++++++++++++++ pkg/ollama/message.go | 53 ++++++++++++++++++++++++---------------- pkg/ollama/opt.go | 1 - 15 files changed, 130 insertions(+), 60 deletions(-) create mode 100644 pkg/agent/context.go create mode 100644 pkg/ollama/generate.go diff --git a/agent.go b/agent.go index 76bc6c5..77e1f73 100644 --- a/agent.go +++ b/agent.go @@ -17,10 +17,4 @@ type Agent interface { // Embedding vector generation Embedding(context.Context, Model, string, ...Opt) ([]float64, error) - - // Create user message context - UserPrompt(string, ...Opt) Context - - // Create the result of calling a tool - ToolResult(id string, opts ...Opt) Context } diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go index 25bd584..b1643b1 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/generate.go @@ -34,7 +34,7 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { } // Generate the content - response, err := agent.Generate(ctx, model, agent.UserPrompt(cmd.Text)) + response, err := agent.Generate(ctx, model, model.UserPrompt(cmd.Text)) if err != nil { return err } diff --git a/go.mod b/go.mod index 4458b85..7cacd61 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/mutablelogic/go-llm go 1.23.5 require ( + github.com/alecthomas/kong v1.7.0 github.com/mutablelogic/go-client v1.0.9 github.com/stretchr/testify v1.9.0 ) diff --git a/go.sum b/go.sum index 974cf49..7327013 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kong v1.7.0 h1:MnT8+5JxFDCvISeI6vgd/mFbAJwueJ/pqQNzZMsiqZE= +github.com/alecthomas/kong v1.7.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/djthorpe/go-errors v1.0.3 h1:GZeMPkC1mx2vteXLI/gvxZS0Ee9zxzwD1mcYyKU5jD0= github.com/djthorpe/go-errors v1.0.3/go.mod h1:HtfrZnMd6HsX75Mtbv9Qcnn0BqOrrFArvCaj3RMnZhY= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/mutablelogic/go-client v1.0.9 h1:Eh4sjQOFDldP/L3IizqkcOD3WigZR+u1VaHTUM4ujYw= github.com/mutablelogic/go-client v1.0.9/go.mod h1:VLyB8j8IBJSK/FXvvqhmq93PRWDKkyLu8R7V2Vudb6A= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/model.go b/model.go index ffaa169..b898e1d 100644 --- a/model.go +++ b/model.go @@ -4,4 +4,10 @@ package llm type Model interface { // Return the name of the model Name() string + + // Create user prompt for a model + UserPrompt(string, ...Opt) Context + + // Create the result of calling a tool for a model + ToolResult(id string, opts ...Opt) Context } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index de701a5..53fc183 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -152,11 +152,6 @@ func (a *Agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]flo return nil, llm.ErrNotImplemented } -// Create user message context -func (a *Agent) UserPrompt(string, ...llm.Opt) llm.Context { - return nil -} - // Create the result of calling a tool func (a *Agent) ToolResult(id string, opts ...llm.Opt) llm.Context { return nil diff --git a/pkg/agent/context.go b/pkg/agent/context.go new file mode 100644 index 0000000..d8d60f4 --- /dev/null +++ b/pkg/agent/context.go @@ -0,0 +1,6 @@ +package agent + +// Packages + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS diff --git a/pkg/agent/generate.go b/pkg/agent/generate.go index d2e8445..3857eec 100644 --- a/pkg/agent/generate.go +++ b/pkg/agent/generate.go @@ -2,6 +2,7 @@ package agent import ( "context" + "log" // Packages llm "github.com/mutablelogic/go-llm" @@ -22,7 +23,13 @@ func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, agent = agent_ } - // TODO: Translate all the opts + // Apply the options + opts, err := translate(agent, opts...) + if err != nil { + return nil, err + } + + log.Print("agent.Generate:", m, context, opts) // Call Generate for the agent return agent.Generate(ctx, m, context, opts...) diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index 804bd9f..9f2203c 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -14,11 +14,16 @@ import ( type opt struct { agents map[string]llm.Agent tools map[string]llm.Tool + + // Selected agent + agent llm.Agent + opts []llm.Opt } //////////////////////////////////////////////////////////////////////////////// // LIFECYCLE +// Apply options func apply(opts ...llm.Opt) (*opt, error) { o := new(opt) o.agents = make(map[string]llm.Agent) @@ -30,6 +35,28 @@ func apply(opts ...llm.Opt) (*opt, error) { return o, nil } +// Translate options from general to agent-specific +func translate(agent llm.Agent, opts ...llm.Opt) ([]llm.Opt, error) { + o := new(opt) + + // Set agent + if agent == nil { + return nil, llm.ErrBadParameter.With("agent") + } else { + o.agent = agent + } + + // Apply options + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + + // Return translated options + return o.opts, nil +} + //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -73,12 +100,19 @@ func WithTools(tools ...llm.Tool) llm.Opt { // PRIVATE METHODS func (o *opt) withAgent(agent llm.Agent) error { + // Check parameters + if agent == nil || o.agents == nil { + return llm.ErrBadParameter.With("withAgent") + } + + // Add agent name := agent.Name() if _, exists := o.agents[name]; exists { return llm.ErrConflict.Withf("Agent %q already exists", name) } else { o.agents[name] = agent } + // Return success return nil } diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 51d24c6..8c1f66b 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -111,7 +111,7 @@ func (m message) Role() string { } // Create user message context -func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { +func (*model) UserPrompt(text string, opts ...llm.Opt) llm.Context { // Get attachments opt, err := apply(opts...) if err != nil { @@ -135,7 +135,7 @@ func (*Client) UserPrompt(text string, opts ...llm.Opt) llm.Context { } // Create the result of calling a tool -func (*Client) ToolResult(id string, opts ...llm.Opt) llm.Context { +func (*model) ToolResult(id string, opts ...llm.Opt) llm.Context { context := new(message) context.MessageMeta.Role = "user" context.MessageMeta.Content = make([]*Content, 0, 1) diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index 1d1cde1..a8e31d2 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -58,31 +58,21 @@ type reqChat struct { KeepAlive *time.Duration `json:"keep_alive,omitempty"` } -func (ollama *Client) Chat(ctx context.Context, name string, prompt llm.Context, opts ...llm.Opt) (*Response, error) { +func (ollama *Client) Chat(ctx context.Context, model string, prompt llm.Context, opts ...llm.Opt) (*Response, error) { // Apply options opt, err := apply(opts...) if err != nil { return nil, err } - // Append the context to the set of messages - messages := make([]*MessageMeta, 0, len(opt.context)+2) - copy(messages, opt.context) - - // Context to append to the request - message, ok := prompt.(*message) - if !ok || message == nil { - return nil, llm.ErrBadParameter.With("incompatible context") - } else if message.Role() != "user" { - return nil, llm.ErrBadParameter.Withf("invalid role, %q", message.Role()) - } else { - messages = append(messages, &message.MessageMeta) - } + // Make a new sequence of messages + seq := make([]*MessageMeta, len(prompt.(*messages).seq)) + copy(seq, prompt.(*messages).seq) // Request req, err := client.NewJSONRequest(reqChat{ - Model: name, - Messages: messages, + Model: model, + Messages: seq, Tools: opt.tools, Format: opt.format, Options: opt.options, @@ -105,7 +95,7 @@ func (ollama *Client) Chat(ctx context.Context, name string, prompt llm.Context, } // Append the response message to the context - response.Context = append(messages, &response.Message) + response.Context = append(seq, &response.Message) // Return success return &response, nil diff --git a/pkg/ollama/client.go b/pkg/ollama/client.go index 9ad28ca..56d9c62 100644 --- a/pkg/ollama/client.go +++ b/pkg/ollama/client.go @@ -1,7 +1,6 @@ package ollama import ( - "context" // Packages client "github.com/mutablelogic/go-client" @@ -48,8 +47,3 @@ func New(endPoint string, opts ...client.ClientOpt) (*Client, error) { func (*Client) Name() string { return defaultName } - -// Generate a response from a prompt -func (ollama *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { - return nil, llm.ErrNotImplemented -} diff --git a/pkg/ollama/generate.go b/pkg/ollama/generate.go new file mode 100644 index 0000000..aa0cd3c --- /dev/null +++ b/pkg/ollama/generate.go @@ -0,0 +1,25 @@ +package ollama + +import ( + "context" + + // Packages + + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Generate a response from a prompt +func (ollama *Client) Generate(ctx context.Context, model llm.Model, prompt llm.Context, opts ...llm.Opt) (llm.Context, error) { + // The prompt should be of type *messages + // Generate a chat response + response, err := ollama.Chat(ctx, model.Name(), prompt, opts...) + if err != nil { + return nil, err + } + + // Return the response + return &messages{seq: response.Context}, nil +} diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index d2c7a3c..ca21fb7 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -10,13 +10,6 @@ import ( /////////////////////////////////////////////////////////////////////////////// // TYPES -// Implementation of a message -type message struct { - MessageMeta -} - -var _ llm.Context = (*message)(nil) - // Chat Message type MessageMeta struct { Role string `json:"role"` @@ -25,14 +18,27 @@ type MessageMeta struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` } +// Implementation of a message session, which is a sequence of messages +type messages struct { + seq []*MessageMeta +} + +var _ llm.Context = (*messages)(nil) + // Data represents the raw binary data of an image file. type Data []byte /////////////////////////////////////////////////////////////////////////////// // STRINGIFY -func (m message) String() string { - data, err := json.MarshalIndent(m.MessageMeta, "", " ") +func (m messages) String() string { + var data []byte + var err error + if len(m.seq) == 1 { + data, err = json.MarshalIndent(m.seq[0], "", " ") + } else { + data, err = json.MarshalIndent(m.seq, "", " ") + } if err != nil { return err.Error() } @@ -43,32 +49,37 @@ func (m message) String() string { // PUBLIC METHODS // Create user message context, with optional images -func (ollama *Client) UserPrompt(v string, opts ...llm.Opt) llm.Context { +func (*model) UserPrompt(v string, opts ...llm.Opt) llm.Context { // Apply options opt, err := apply(opts...) if err != nil { return nil } - m := new(message) - m.MessageMeta.Role = "user" - m.MessageMeta.Content = v + var meta MessageMeta + meta.Role = "user" + meta.Content = v if len(opt.data) > 0 { - m.MessageMeta.Images = make([]Data, len(opt.data)) - copy(m.MessageMeta.Images, opt.data) + meta.Images = make([]Data, len(opt.data)) + copy(meta.Images, opt.data) } - // Return success - return m + // Return prompt + return &messages{ + seq: []*MessageMeta{&meta}, + } } // The result of a tool call -func (ollama *Client) ToolResult(id string, opts ...llm.Opt) llm.Context { +func (*model) ToolResult(id string, opts ...llm.Opt) llm.Context { // messages.append({'role': 'tool', 'content': str(output), 'name': tool.function.name}) return nil } -// Return the role of a message -func (m message) Role() string { - return m.MessageMeta.Role +// Return the role of the last message +func (m messages) Role() string { + if len(m.seq) == 0 { + return "" + } + return m.seq[len(m.seq)-1].Role } diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go index a8a6079..ee04a7f 100644 --- a/pkg/ollama/opt.go +++ b/pkg/ollama/opt.go @@ -20,7 +20,6 @@ type opt struct { truncate *bool keepalive *time.Duration options map[string]any - context []*MessageMeta tools []*Tool data []Data } From c8d9dba59d4066e7688ecc2d859e440ba40514b7 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 09:30:25 +0100 Subject: [PATCH 14/33] Updated LLM --- cmd/agent/generate.go | 43 ++++++++++++++---- cmd/agent/main.go | 12 ++++- cmd/agent/term.go | 66 +++++++++++++++++++++++++++ context.go | 13 +++++- model.go | 7 +-- pkg/agent/agent.go | 5 +-- pkg/agent/generate.go | 2 +- pkg/anthropic/context.go | 95 +++++++++++++++++++++++++++++++++++++++ pkg/anthropic/message.go | 56 ----------------------- pkg/anthropic/messages.go | 13 +----- pkg/ollama/chat.go | 9 ++-- pkg/ollama/context.go | 91 +++++++++++++++++++++++++++++++++++++ pkg/ollama/generate.go | 2 +- pkg/ollama/message.go | 70 +---------------------------- 14 files changed, 321 insertions(+), 163 deletions(-) create mode 100644 cmd/agent/term.go create mode 100644 pkg/anthropic/context.go create mode 100644 pkg/ollama/context.go diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go index b1643b1..a8a111a 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/generate.go @@ -2,7 +2,10 @@ package main import ( "context" + "errors" "fmt" + "io" + "strings" // Packages llm "github.com/mutablelogic/go-llm" @@ -14,7 +17,6 @@ import ( type GenerateCmd struct { Model string `arg:"" help:"Model name"` - Text string `arg:"" help:"Text to generate a response for"` } //////////////////////////////////////////////////////////////////////////////// @@ -23,7 +25,6 @@ type GenerateCmd struct { func (cmd *GenerateCmd) Run(globals *Globals) error { return runagent(globals, func(ctx context.Context, client llm.Agent) error { // Get the model - // TODO: Model should be cached agent, ok := client.(*agent.Agent) if !ok { return fmt.Errorf("No agents found") @@ -33,14 +34,38 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { return err } - // Generate the content - response, err := agent.Generate(ctx, model, model.UserPrompt(cmd.Text)) - if err != nil { - return err + // Create a session + session := model.Context() + + // Continue looping until end of input + for { + input, err := globals.term.ReadLine(model.Name() + "> ") + if errors.Is(err, io.EOF) { + return nil + } else if err != nil { + return err + } else if err := session.AppendUserPrompt(strings.TrimSpace(input)); err != nil { + return err + } + + // Ignore empty import + if session.Text() == "" { + continue + } + + // Feed input into the model + response, err := agent.Generate(ctx, model, session) + if err != nil { + return err + } + fmt.Println("RESPONSE=", response.Text()) } + /* + // Generate the content + - // Print the response - fmt.Println("RESPONSE=", response) - return nil + // Print the response + return nil + */ }) } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index e8745bd..53884df 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -11,7 +11,7 @@ import ( kong "github.com/alecthomas/kong" client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" - "github.com/mutablelogic/go-llm/pkg/agent" + agent "github.com/mutablelogic/go-llm/pkg/agent" ) //////////////////////////////////////////////////////////////////////////////// @@ -29,6 +29,7 @@ type Globals struct { // Context ctx context.Context agent llm.Agent + term *Term } type Ollama struct { @@ -67,6 +68,15 @@ func main() { defer cancel() cli.Globals.ctx = ctx + // Create a terminal + term, err := NewTerm(os.Stdout) + if err != nil { + cmd.FatalIfErrorf(err) + return + } else { + cli.Globals.term = term + } + // Client options clientopts := []client.ClientOpt{} if cli.Debug || cli.Verbose { diff --git a/cmd/agent/term.go b/cmd/agent/term.go new file mode 100644 index 0000000..9491f1d --- /dev/null +++ b/cmd/agent/term.go @@ -0,0 +1,66 @@ +package main + +import ( + "io" + "os" + + // Packages + "golang.org/x/term" +) + +type Term struct { + r io.Reader + fd int + *term.Terminal +} + +func NewTerm(r io.Reader) (*Term, error) { + t := new(Term) + t.r = r + + // Set file descriptor + if osf, ok := r.(*os.File); ok { + t.fd = int(osf.Fd()) + if term.IsTerminal(t.fd) { + t.Terminal = term.NewTerminal(osf, "") + } + } + + // Return success + return t, nil +} + +// Returns the width and height of the terminal, or (0,0) +func (t *Term) Size() (int, int) { + if t.Terminal != nil { + if w, h, err := term.GetSize(t.fd); err == nil { + return w, h + } + } + // Unable to get the size + return 0, 0 +} + +func (t *Term) ReadLine(prompt string) (string, error) { + // Set terminal raw mode + if t.Terminal != nil { + state, err := term.MakeRaw(t.fd) + if err != nil { + return "", err + } + defer term.Restore(t.fd, state) + } + + // Set the prompt + if t.Terminal != nil { + t.Terminal.SetPrompt(prompt) + } + + // Read the line + if t.Terminal != nil { + return t.Terminal.ReadLine() + } else { + // Don't support non-terminal input yet + return "", io.EOF + } +} diff --git a/context.go b/context.go index fb479d2..a5250a0 100644 --- a/context.go +++ b/context.go @@ -3,8 +3,17 @@ package llm ////////////////////////////////////////////////////////////////// // TYPES -// Context is fed to the agent to generate a response. Role can be -// assistant, user, tool, tool_result, ... +// Context is fed to the agent to generate a response type Context interface { + // Return the role, which can be assistant, user, tool, tool_result, ... Role() string + + // Return the text of the context + Text() string + + // Append user prompt (and attachments) to a context + AppendUserPrompt(string, ...Opt) error + + // Append the result of calling a tool to a context + AppendToolResult(string, ...Opt) error } diff --git a/model.go b/model.go index b898e1d..49863e3 100644 --- a/model.go +++ b/model.go @@ -5,9 +5,6 @@ type Model interface { // Return the name of the model Name() string - // Create user prompt for a model - UserPrompt(string, ...Opt) Context - - // Create the result of calling a tool for a model - ToolResult(id string, opts ...Opt) Context + // Return a context object, and set options + Context(...Opt) Context } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 53fc183..9a7f638 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -28,11 +28,10 @@ var _ llm.Agent = (*Agent)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// Return a new agent, composed of agents and tools +// Return a new agent, composed of a series of agents and tools func New(opts ...llm.Opt) (*Agent, error) { agent := new(Agent) - opt, err := apply(opts...) - if err != nil { + if opt, err := apply(opts...); err != nil { return nil, err } else { agent.opt = opt diff --git a/pkg/agent/generate.go b/pkg/agent/generate.go index 3857eec..5ef695a 100644 --- a/pkg/agent/generate.go +++ b/pkg/agent/generate.go @@ -29,7 +29,7 @@ func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, return nil, err } - log.Print("agent.Generate:", m, context, opts) + log.Print("agent.Generate =>", context, opts) // Call Generate for the agent return agent.Generate(ctx, m, context, opts...) diff --git a/pkg/anthropic/context.go b/pkg/anthropic/context.go new file mode 100644 index 0000000..578de67 --- /dev/null +++ b/pkg/anthropic/context.go @@ -0,0 +1,95 @@ +package anthropic + +import ( + "encoding/json" + + llm "github.com/mutablelogic/go-llm" +) + +////////////////////////////////////////////////////////////////// +// TYPES + +type session struct { + seq []*MessageMeta +} + +var _ llm.Context = (*session)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func (*model) Context(...llm.Opt) llm.Context { + // TODO: Currently ignoring options + return &session{} +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (session session) String() string { + var data []byte + var err error + if len(session.seq) == 1 { + data, err = json.MarshalIndent(session.seq[0], "", " ") + } else { + data, err = json.MarshalIndent(session.seq, "", " ") + } + if err != nil { + return err.Error() + } + return string(data) +} + +////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return the role of the last message +func (session *session) Role() string { + if len(session.seq) == 0 { + return "" + } + return session.seq[len(session.seq)-1].Role +} + +// Return the text of the last message +func (session *session) Text() string { + if len(session.seq) == 0 { + return "" + } + meta := session.seq[len(session.seq)-1] + data, err := json.MarshalIndent(meta.Content, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +// Append user prompt (and attachments) to a context +func (session *session) AppendUserPrompt(text string, opts ...llm.Opt) error { + // Apply attachments + opt, err := apply(opts...) + if err != nil { + return err + } + + meta := MessageMeta{ + Role: "user", + Content: make([]*Content, 1, len(opt.data)+1), + } + + // Append the text + meta.Content[0] = NewTextContent(text) + + // Append any additional data + for _, data := range opt.data { + meta.Content = append(meta.Content, data) + } + + // Return success + return nil +} + +// Append the result of calling a tool to a context +func (session *session) AppendToolResult(string, ...llm.Opt) error { + return llm.ErrNotImplemented +} diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 8c1f66b..083296a 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -14,13 +14,6 @@ import ( /////////////////////////////////////////////////////////////////////////////// // TYPES -// Implementation of a message -type message struct { - MessageMeta -} - -var _ llm.Context = (*message)(nil) - // Message with text or object content type MessageMeta struct { Role string `json:"role"` @@ -87,14 +80,6 @@ func NewTextContent(v string) *Content { /////////////////////////////////////////////////////////////////////////////// // STRINGIFY -func (m message) String() string { - data, err := json.MarshalIndent(m.MessageMeta, "", " ") - if err != nil { - return err.Error() - } - return string(data) -} - func (m MessageMeta) String() string { data, err := json.MarshalIndent(m, "", " ") if err != nil { @@ -103,47 +88,6 @@ func (m MessageMeta) String() string { return string(data) } -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -func (m message) Role() string { - return m.MessageMeta.Role -} - -// Create user message context -func (*model) UserPrompt(text string, opts ...llm.Opt) llm.Context { - // Get attachments - opt, err := apply(opts...) - if err != nil { - return nil - } - - context := new(message) - context.MessageMeta.Role = "user" - context.MessageMeta.Content = make([]*Content, 0, len(opt.data)+1) - - // Append the text - context.MessageMeta.Content = append(context.MessageMeta.Content, NewTextContent(text)) - - // Append any additional data - for _, data := range opt.data { - context.MessageMeta.Content = append(context.MessageMeta.Content, data) - } - - // Return the context - return context -} - -// Create the result of calling a tool -func (*model) ToolResult(id string, opts ...llm.Opt) llm.Context { - context := new(message) - context.MessageMeta.Role = "user" - context.MessageMeta.Content = make([]*Content, 0, 1) - - // Return the context - return context -} - /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 1e6ce4a..8c74ee2 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -68,17 +68,6 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context return nil, err } - // Context to append to the request - messages := []*MessageMeta{} - message, ok := context.(*message) - if !ok || message == nil { - return nil, llm.ErrBadParameter.With("incompatible context") - } else if message.Role() != "user" { - return nil, llm.ErrBadParameter.Withf("invalid role, %q", message.Role()) - } else { - messages = append(messages, &message.MessageMeta) - } - // Set max_tokens if opt.MaxTokens == 0 { opt.MaxTokens = defaultMaxTokens(model.Name()) @@ -87,7 +76,7 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context // Request req, err := client.NewJSONRequest(reqMessages{ Model: model.Name(), - Messages: messages, + Messages: context.(*session).seq, opt: *opt, }) if err != nil { diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index a8e31d2..7efe5c9 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -66,13 +66,13 @@ func (ollama *Client) Chat(ctx context.Context, model string, prompt llm.Context } // Make a new sequence of messages - seq := make([]*MessageMeta, len(prompt.(*messages).seq)) - copy(seq, prompt.(*messages).seq) + seq := make([]*MessageMeta, len(prompt.(*session).seq)) + copy(seq, prompt.(*session).seq) // Request req, err := client.NewJSONRequest(reqChat{ Model: model, - Messages: seq, + Messages: prompt.(*session).seq, Tools: opt.tools, Format: opt.format, Options: opt.options, @@ -95,7 +95,8 @@ func (ollama *Client) Chat(ctx context.Context, model string, prompt llm.Context } // Append the response message to the context - response.Context = append(seq, &response.Message) + prompt.(*session).seq = append(prompt.(*session).seq, &response.Message) + response.Context = prompt.(*session).seq // Return success return &response, nil diff --git a/pkg/ollama/context.go b/pkg/ollama/context.go new file mode 100644 index 0000000..e3344ad --- /dev/null +++ b/pkg/ollama/context.go @@ -0,0 +1,91 @@ +package ollama + +import ( + "encoding/json" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Implementation of a message session, which is a sequence of messages +type session struct { + seq []*MessageMeta +} + +var _ llm.Context = (*session)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func (*model) Context(...llm.Opt) llm.Context { + // TODO: Currently ignoring options + return &session{} +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (session session) String() string { + var data []byte + var err error + if len(session.seq) == 1 { + data, err = json.MarshalIndent(session.seq[0], "", " ") + } else { + data, err = json.MarshalIndent(session.seq, "", " ") + } + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Append user message context, with optional images +func (session *session) AppendUserPrompt(v string, opts ...llm.Opt) error { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil + } + + var meta MessageMeta + meta.Role = "user" + meta.Content = v + if len(opt.data) > 0 { + meta.Images = make([]Data, len(opt.data)) + copy(meta.Images, opt.data) + } + + // Append the message + session.seq = append(session.seq, &meta) + + // Return success + return nil +} + +// Append the result of a tool call +func (session *session) AppendToolResult(id string, opts ...llm.Opt) error { + // messages.append({'role': 'tool', 'content': str(output), 'name': tool.function.name}) + return nil +} + +// Return the role of the last message +func (session *session) Role() string { + if len(session.seq) == 0 { + return "" + } + return session.seq[len(session.seq)-1].Role +} + +// Return the text of the last message +func (session *session) Text() string { + if len(session.seq) == 0 { + return "" + } + return session.seq[len(session.seq)-1].Content +} diff --git a/pkg/ollama/generate.go b/pkg/ollama/generate.go index aa0cd3c..2097973 100644 --- a/pkg/ollama/generate.go +++ b/pkg/ollama/generate.go @@ -21,5 +21,5 @@ func (ollama *Client) Generate(ctx context.Context, model llm.Model, prompt llm. } // Return the response - return &messages{seq: response.Context}, nil + return &session{seq: response.Context}, nil } diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index ca21fb7..eb5be09 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -1,11 +1,6 @@ package ollama -import ( - "encoding/json" - - // Packages - llm "github.com/mutablelogic/go-llm" -) +// Packages /////////////////////////////////////////////////////////////////////////////// // TYPES @@ -18,68 +13,5 @@ type MessageMeta struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` } -// Implementation of a message session, which is a sequence of messages -type messages struct { - seq []*MessageMeta -} - -var _ llm.Context = (*messages)(nil) - // Data represents the raw binary data of an image file. type Data []byte - -/////////////////////////////////////////////////////////////////////////////// -// STRINGIFY - -func (m messages) String() string { - var data []byte - var err error - if len(m.seq) == 1 { - data, err = json.MarshalIndent(m.seq[0], "", " ") - } else { - data, err = json.MarshalIndent(m.seq, "", " ") - } - if err != nil { - return err.Error() - } - return string(data) -} - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// Create user message context, with optional images -func (*model) UserPrompt(v string, opts ...llm.Opt) llm.Context { - // Apply options - opt, err := apply(opts...) - if err != nil { - return nil - } - - var meta MessageMeta - meta.Role = "user" - meta.Content = v - if len(opt.data) > 0 { - meta.Images = make([]Data, len(opt.data)) - copy(meta.Images, opt.data) - } - - // Return prompt - return &messages{ - seq: []*MessageMeta{&meta}, - } -} - -// The result of a tool call -func (*model) ToolResult(id string, opts ...llm.Opt) llm.Context { - // messages.append({'role': 'tool', 'content': str(output), 'name': tool.function.name}) - return nil -} - -// Return the role of the last message -func (m messages) Role() string { - if len(m.seq) == 0 { - return "" - } - return m.seq[len(m.seq)-1].Role -} From 40ee5b3ed4b1275e5378628309658ba8b429ea84 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 10:11:16 +0100 Subject: [PATCH 15/33] Updated --- cmd/agent/generate.go | 17 +++++++--------- model.go | 2 +- pkg/agent/generate.go | 12 ++++++----- pkg/agent/opt.go | 44 +++++++++++++++++----------------------- pkg/anthropic/context.go | 4 ++-- pkg/ollama/context.go | 15 +++++++++++--- pkg/ollama/opt.go | 6 +++--- 7 files changed, 51 insertions(+), 49 deletions(-) diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go index a8a111a..b4e11a1 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/generate.go @@ -16,7 +16,8 @@ import ( // TYPES type GenerateCmd struct { - Model string `arg:"" help:"Model name"` + Model string `arg:"" help:"Model name"` + NoStream bool `flag:"nostream" help:"Disable streaming"` } //////////////////////////////////////////////////////////////////////////////// @@ -35,7 +36,10 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { } // Create a session - session := model.Context() + session, err := model.Context(agent.WithStream(!cmd.NoStream)) + if err != nil { + return err + } // Continue looping until end of input for { @@ -58,14 +62,7 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { if err != nil { return err } - fmt.Println("RESPONSE=", response.Text()) + fmt.Println(response.Text()) } - /* - // Generate the content - - - // Print the response - return nil - */ }) } diff --git a/model.go b/model.go index 49863e3..2d7d965 100644 --- a/model.go +++ b/model.go @@ -6,5 +6,5 @@ type Model interface { Name() string // Return a context object, and set options - Context(...Opt) Context + Context(...Opt) (Context, error) } diff --git a/pkg/agent/generate.go b/pkg/agent/generate.go index 5ef695a..5148962 100644 --- a/pkg/agent/generate.go +++ b/pkg/agent/generate.go @@ -23,13 +23,15 @@ func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, agent = agent_ } + // Get the options + // Apply the options - opts, err := translate(agent, opts...) - if err != nil { - return nil, err - } + //opts, err := translate(agent, opts...) + //if err != nil { + // return nil, err + //} - log.Print("agent.Generate =>", context, opts) + log.Print("agent.Generate =>", context) // Call Generate for the agent return agent.Generate(ctx, m, context, opts...) diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index 9f2203c..f08c419 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -2,6 +2,8 @@ package agent import ( // Packages + "fmt" + client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" @@ -15,9 +17,9 @@ type opt struct { agents map[string]llm.Agent tools map[string]llm.Tool - // Selected agent - agent llm.Agent - opts []llm.Opt + // Translated options for each agent implementation + ollama []llm.Opt + anthropic []llm.Opt } //////////////////////////////////////////////////////////////////////////////// @@ -35,28 +37,6 @@ func apply(opts ...llm.Opt) (*opt, error) { return o, nil } -// Translate options from general to agent-specific -func translate(agent llm.Agent, opts ...llm.Opt) ([]llm.Opt, error) { - o := new(opt) - - // Set agent - if agent == nil { - return nil, llm.ErrBadParameter.With("agent") - } else { - o.agent = agent - } - - // Apply options - for _, opt := range opts { - if err := opt(o); err != nil { - return nil, err - } - } - - // Return translated options - return o.opts, nil -} - //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -82,6 +62,7 @@ func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { } } +// Append tools func WithTools(tools ...llm.Tool) llm.Opt { return func(o any) error { for _, tool := range tools { @@ -96,6 +77,19 @@ func WithTools(tools ...llm.Tool) llm.Opt { } } +// Set streaming function +func WithStream(v bool) llm.Opt { + return func(o any) error { + o.(*opt).ollama = append(o.(*opt).ollama, ollama.WithStream(func(r *ollama.Response) { + fmt.Println(r) + })) + o.(*opt).anthropic = append(o.(*opt).anthropic, anthropic.WithStream(func(r *anthropic.Response) { + fmt.Println(r) + })) + return nil + } +} + //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/anthropic/context.go b/pkg/anthropic/context.go index 578de67..8564da8 100644 --- a/pkg/anthropic/context.go +++ b/pkg/anthropic/context.go @@ -18,9 +18,9 @@ var _ llm.Context = (*session)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -func (*model) Context(...llm.Opt) llm.Context { +func (*model) Context(...llm.Opt) (llm.Context, error) { // TODO: Currently ignoring options - return &session{} + return &session{}, nil } /////////////////////////////////////////////////////////////////////////////// diff --git a/pkg/ollama/context.go b/pkg/ollama/context.go index e3344ad..1fe5935 100644 --- a/pkg/ollama/context.go +++ b/pkg/ollama/context.go @@ -12,6 +12,7 @@ import ( // Implementation of a message session, which is a sequence of messages type session struct { + *opt seq []*MessageMeta } @@ -20,9 +21,17 @@ var _ llm.Context = (*session)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -func (*model) Context(...llm.Opt) llm.Context { - // TODO: Currently ignoring options - return &session{} +func (*model) Context(opts ...llm.Opt) (llm.Context, error) { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil, err + } + + // Return an empty session + return &session{ + opt: opt, + }, nil } /////////////////////////////////////////////////////////////////////////////// diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go index ee04a7f..f4a7541 100644 --- a/pkg/ollama/opt.go +++ b/pkg/ollama/opt.go @@ -80,13 +80,13 @@ func WithPullStatus(fn func(*PullStatus)) llm.Opt { } // Chat: Stream the response as it is received. -func WithChatStream(fn func(*Response)) llm.Opt { +func WithStream(fn func(*Response)) llm.Opt { return func(o any) error { if fn == nil { return llm.ErrBadParameter.With("callback required") } if len(o.(*opt).tools) > 0 { - return llm.ErrBadParameter.With("tools not supported with streaming") + return llm.ErrBadParameter.With("streaming not supported with tools") } o.(*opt).stream = true o.(*opt).chatcallback = fn @@ -99,7 +99,7 @@ func WithTool(v *Tool) llm.Opt { return func(o any) error { // We can't use streaming when tools are included if o.(*opt).stream { - return llm.ErrBadParameter.With("streaming not supported with tools") + return llm.ErrBadParameter.With("tools not supported with streaming") } if v != nil { o.(*opt).tools = append(o.(*opt).tools, v) From 3fc246bcd7dffbd40069e8c33008dfd9e704fe08 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 10:25:53 +0100 Subject: [PATCH 16/33] Updated LLM --- agent.go | 3 --- context.go | 15 ++++++++++----- model.go | 7 +++++-- opt.go | 1 + pkg/agent/opt.go | 6 +++--- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/agent.go b/agent.go index 77e1f73..56672ec 100644 --- a/agent.go +++ b/agent.go @@ -12,9 +12,6 @@ type Agent interface { // Return the models Models(context.Context) ([]Model, error) - // Generate a response from a prompt - Generate(context.Context, Model, Context, ...Opt) (Context, error) - // Embedding vector generation Embedding(context.Context, Model, string, ...Opt) ([]float64, error) } diff --git a/context.go b/context.go index a5250a0..95aa523 100644 --- a/context.go +++ b/context.go @@ -1,19 +1,24 @@ package llm +import "context" + ////////////////////////////////////////////////////////////////// // TYPES // Context is fed to the agent to generate a response type Context interface { - // Return the role, which can be assistant, user, tool, tool_result, ... - Role() string - - // Return the text of the context - Text() string + // Generate a response from the context + Generate(context.Context, Model) (Context, error) // Append user prompt (and attachments) to a context AppendUserPrompt(string, ...Opt) error // Append the result of calling a tool to a context AppendToolResult(string, ...Opt) error + + // Return the role, which can be assistant, user, tool, tool_result, ... + Role() string + + // Return the text of the context + Text() string } diff --git a/model.go b/model.go index 2d7d965..8754bde 100644 --- a/model.go +++ b/model.go @@ -1,10 +1,13 @@ package llm -// An Model can be used to generate a response +// An Model can be used to generate a response to a user prompt, +// which is passed to an agent. The interaction occurs through +// a session context object. type Model interface { // Return the name of the model Name() string - // Return a context object, and set options + // Return am empty session context object for the model, + // setting session options Context(...Opt) (Context, error) } diff --git a/opt.go b/opt.go index e821b83..991c6f1 100644 --- a/opt.go +++ b/opt.go @@ -3,4 +3,5 @@ package llm /////////////////////////////////////////////////////////////////////////////// // TYPES +// A generic option type, which can set options on an agent or session type Opt func(any) error diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index f08c419..241285b 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -1,9 +1,9 @@ package agent import ( - // Packages "fmt" + // Packages client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" @@ -81,10 +81,10 @@ func WithTools(tools ...llm.Tool) llm.Opt { func WithStream(v bool) llm.Opt { return func(o any) error { o.(*opt).ollama = append(o.(*opt).ollama, ollama.WithStream(func(r *ollama.Response) { - fmt.Println(r) + fmt.Println("OLLAMA STREAM", r) })) o.(*opt).anthropic = append(o.(*opt).anthropic, anthropic.WithStream(func(r *anthropic.Response) { - fmt.Println(r) + fmt.Println("ANTHROPIC STREAM", r) })) return nil } From 31a65ca586b689fccac02a02119b1e53f0928e3a Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 10:40:16 +0100 Subject: [PATCH 17/33] Updated LLM --- context.go | 17 +++++++---------- pkg/agent/agent.go | 2 +- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/context.go b/context.go index 95aa523..b582a0f 100644 --- a/context.go +++ b/context.go @@ -7,18 +7,15 @@ import "context" // Context is fed to the agent to generate a response type Context interface { - // Generate a response from the context - Generate(context.Context, Model) (Context, error) - - // Append user prompt (and attachments) to a context - AppendUserPrompt(string, ...Opt) error - - // Append the result of calling a tool to a context - AppendToolResult(string, ...Opt) error - - // Return the role, which can be assistant, user, tool, tool_result, ... + // Return the role, which can be system, assistant, user, tool, tool_result, ... Role() string // Return the text of the context Text() string + + // Generate a response from a user prompt (with attachments) + FromUser(context.Context, string, ...Opt) (Context, error) + + // Generate a response from a tool result + FromTool(context.Context, ...any) (Context, error) } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 9a7f638..a22ec7d 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -28,7 +28,7 @@ var _ llm.Agent = (*Agent)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// Return a new agent, composed of a series of agents and tools +// Return a new agent, composed of a series of agents and tools func New(opts ...llm.Opt) (*Agent, error) { agent := new(Agent) if opt, err := apply(opts...); err != nil { From 33340101b6f4cd15a0d125e11d3aa02fe1d76c38 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 13:27:36 +0100 Subject: [PATCH 18/33] Updated llm --- cmd/agent/main.go | 7 +- cmd/agent/models.go | 40 +++++++++ context.go | 4 +- go.mod | 6 +- go.sum | 12 ++- model.go | 4 + pkg/agent/agent.go | 16 ++-- pkg/ollama/chat.go | 53 ++++++----- pkg/ollama/chat_test.go | 31 +++---- pkg/ollama/context.go | 100 --------------------- pkg/ollama/generate.go | 25 ------ pkg/ollama/message.go | 9 +- pkg/ollama/model.go | 93 ++++++++++---------- pkg/ollama/model_test.go | 3 +- pkg/ollama/session.go | 176 +++++++++++++++++++++++++++++++++++++ pkg/ollama/session_test.go | 89 +++++++++++++++++++ pkg/ollama/tool.go | 3 + 17 files changed, 442 insertions(+), 229 deletions(-) delete mode 100644 pkg/ollama/context.go delete mode 100644 pkg/ollama/generate.go create mode 100644 pkg/ollama/session.go create mode 100644 pkg/ollama/session_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 53884df..9e9b727 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -44,9 +44,10 @@ type CLI struct { Globals // Agents, Models and Tools - Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` - Models ListModelsCmd `cmd:"" help:"Return a list of models"` - Generate GenerateCmd `cmd:"" help:"Generate a response"` + Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` + Models ListModelsCmd `cmd:"" help:"Return a list of models"` + Download DownloadModelCmd `cmd:"" help:"Download a model"` + Generate GenerateCmd `cmd:"" help:"Generate a response"` } //////////////////////////////////////////////////////////////////////////////// diff --git a/cmd/agent/models.go b/cmd/agent/models.go index b710159..4a04bd7 100644 --- a/cmd/agent/models.go +++ b/cmd/agent/models.go @@ -7,6 +7,7 @@ import ( // Packages llm "github.com/mutablelogic/go-llm" agent "github.com/mutablelogic/go-llm/pkg/agent" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" ) //////////////////////////////////////////////////////////////////////////////// @@ -18,6 +19,11 @@ type ListModelsCmd struct { type ListAgentsCmd struct{} +type DownloadModelCmd struct { + Agent string `arg:"" help:"Agent name"` + Model string `arg:"" help:"Model name"` +} + //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -49,9 +55,43 @@ func (*ListAgentsCmd) Run(globals *Globals) error { }) } +func (cmd *DownloadModelCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, client llm.Agent) error { + agent := getagent(client, cmd.Agent) + if agent == nil { + return fmt.Errorf("No agents found with name %q", cmd.Agent) + } + // Download the model + switch agent.Name() { + case "ollama": + model, err := agent.(*ollama.Client).PullModel(ctx, cmd.Model) + if err != nil { + return err + } + fmt.Println(model) + default: + return fmt.Errorf("Agent %q does not support model download", agent.Name()) + } + return nil + }) +} + //////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS func runagent(globals *Globals, fn func(ctx context.Context, agent llm.Agent) error) error { return fn(globals.ctx, globals.agent) } + +func getagent(client llm.Agent, name string) llm.Agent { + agent, ok := client.(*agent.Agent) + if !ok { + return nil + } + for _, agent := range agent.Agents() { + if agent.Name() == name { + return agent + } + } + return nil +} diff --git a/context.go b/context.go index b582a0f..e98ffa6 100644 --- a/context.go +++ b/context.go @@ -16,6 +16,6 @@ type Context interface { // Generate a response from a user prompt (with attachments) FromUser(context.Context, string, ...Opt) (Context, error) - // Generate a response from a tool result - FromTool(context.Context, ...any) (Context, error) + // Generate a response from a tool, passing the call identifier or funtion name, and the result + FromTool(context.Context, string, any) (Context, error) } diff --git a/go.mod b/go.mod index 7cacd61..597559b 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,15 @@ go 1.23.5 require ( github.com/alecthomas/kong v1.7.0 - github.com/mutablelogic/go-client v1.0.9 - github.com/stretchr/testify v1.9.0 + github.com/mutablelogic/go-client v1.0.10 + github.com/stretchr/testify v1.10.0 + golang.org/x/term v0.28.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/djthorpe/go-errors v1.0.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7327013..78507c5 100644 --- a/go.sum +++ b/go.sum @@ -10,12 +10,16 @@ github.com/djthorpe/go-errors v1.0.3 h1:GZeMPkC1mx2vteXLI/gvxZS0Ee9zxzwD1mcYyKU5 github.com/djthorpe/go-errors v1.0.3/go.mod h1:HtfrZnMd6HsX75Mtbv9Qcnn0BqOrrFArvCaj3RMnZhY= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/mutablelogic/go-client v1.0.9 h1:Eh4sjQOFDldP/L3IizqkcOD3WigZR+u1VaHTUM4ujYw= -github.com/mutablelogic/go-client v1.0.9/go.mod h1:VLyB8j8IBJSK/FXvvqhmq93PRWDKkyLu8R7V2Vudb6A= +github.com/mutablelogic/go-client v1.0.10 h1:d4t8irXlGNQrQS/+FoUht+1RnjL9lBaf1e2UasN3ifE= +github.com/mutablelogic/go-client v1.0.10/go.mod h1:XbG8KGo2Efi7PGxXs7rhYxYhLeXL6aCSo6sz0mVchiw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/model.go b/model.go index 8754bde..9ee6bd0 100644 --- a/model.go +++ b/model.go @@ -10,4 +10,8 @@ type Model interface { // Return am empty session context object for the model, // setting session options Context(...Opt) (Context, error) + + // Convenience method to create a session context object + // with a user prompt, which panics on error + MustUserPrompt(string, ...Opt) Context } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index a22ec7d..2876f9e 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -56,12 +56,12 @@ func (m model) String() string { // PUBLIC METHODS // Return a list of agent names -func (a *Agent) Agents() []string { - var keys []string - for k := range a.agents { - keys = append(keys, k) +func (a *Agent) Agents() []llm.Agent { + var result []llm.Agent + for _, v := range a.agents { + result = append(result, v) } - return keys + return result } // Return a list of tool names @@ -75,7 +75,11 @@ func (a *Agent) Tools() []string { // Return a comma-separated list of agent names func (a *Agent) Name() string { - return strings.Join(a.Agents(), ",") + var keys []string + for key := range a.agents { + keys = append(keys, key) + } + return strings.Join(keys, ",") } // Return the models from all agents diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index 7efe5c9..ccd01b4 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -15,12 +15,11 @@ import ( // Chat Response type Response struct { - Model string `json:"model"` - CreatedAt time.Time `json:"created_at"` - Message MessageMeta `json:"message"` - Done bool `json:"done"` - Reason string `json:"done_reason,omitempty"` - Context []*MessageMeta `json:"-"` + Model string `json:"model"` + CreatedAt time.Time `json:"created_at"` + Message MessageMeta `json:"message"` + Done bool `json:"done"` + Reason string `json:"done_reason,omitempty"` Metrics } @@ -58,20 +57,16 @@ type reqChat struct { KeepAlive *time.Duration `json:"keep_alive,omitempty"` } -func (ollama *Client) Chat(ctx context.Context, model string, prompt llm.Context, opts ...llm.Opt) (*Response, error) { +func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm.Opt) (*Response, error) { // Apply options opt, err := apply(opts...) if err != nil { return nil, err } - // Make a new sequence of messages - seq := make([]*MessageMeta, len(prompt.(*session).seq)) - copy(seq, prompt.(*session).seq) - // Request req, err := client.NewJSONRequest(reqChat{ - Model: model, + Model: prompt.(*session).model.Name(), Messages: prompt.(*session).seq, Tools: opt.tools, Format: opt.format, @@ -84,20 +79,34 @@ func (ollama *Client) Chat(ctx context.Context, model string, prompt llm.Context } // Response - var response Response - if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("chat"), client.OptJsonStreamCallback(func(v any) error { - if v, ok := v.(*Response); ok && opt.chatcallback != nil { - opt.chatcallback(v) + var response, delta Response + if err := ollama.DoWithContext(ctx, req, &delta, client.OptPath("chat"), client.OptJsonStreamCallback(func(v any) error { + if v, ok := v.(*Response); !ok || v == nil { + return llm.ErrConflict.Withf("Invalid stream response: %v", v) + } else { + response.Model = v.Model + response.CreatedAt = v.CreatedAt + response.Message.Role = v.Message.Role + response.Message.Content += v.Message.Content + if v.Done { + response.Done = v.Done + response.Metrics = v.Metrics + response.Reason = v.Reason + } + } + + if opt.chatcallback != nil { + opt.chatcallback(&response) } return nil })); err != nil { return nil, err } - // Append the response message to the context - prompt.(*session).seq = append(prompt.(*session).seq, &response.Message) - response.Context = prompt.(*session).seq - - // Return success - return &response, nil + // We return the delta or the response + if opt.stream { + return &response, nil + } else { + return &delta, nil + } } diff --git a/pkg/ollama/chat_test.go b/pkg/ollama/chat_test.go index 088168d..c5f6545 100644 --- a/pkg/ollama/chat_test.go +++ b/pkg/ollama/chat_test.go @@ -18,15 +18,16 @@ func Test_chat_001(t *testing.T) { } // Pull the model - if err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + model, err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { t.Log(status) - })); err != nil { + })) + if err != nil { t.FailNow() } t.Run("ChatStream", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), "qwen:0.5b", client.UserPrompt("why is the sky blue?"), ollama.WithChatStream(func(stream *ollama.Response) { + response, err := client.Chat(context.TODO(), model.MustUserPrompt("why is the sky blue?"), ollama.WithStream(func(stream *ollama.Response) { t.Log(stream) })) if !assert.NoError(err) { @@ -37,7 +38,7 @@ func Test_chat_001(t *testing.T) { t.Run("ChatNoStream", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), "qwen:0.5b", client.UserPrompt("why is the sky green?")) + response, err := client.Chat(context.TODO(), model.MustUserPrompt("why is the sky green?")) if !assert.NoError(err) { t.FailNow() } @@ -52,16 +53,17 @@ func Test_chat_002(t *testing.T) { } // Pull the model - if err := client.PullModel(context.TODO(), "llama3.2:1b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + model, err := client.PullModel(context.TODO(), "llama3.2:1b", ollama.WithPullStatus(func(status *ollama.PullStatus) { t.Log(status) - })); err != nil { + })) + if err != nil { t.FailNow() } t.Run("Tools", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), "llama3.2:1b", - client.UserPrompt("what is the weather in berlin?"), + response, err := client.Chat(context.TODO(), + model.MustUserPrompt("what is the weather in berlin?"), ollama.WithTool(ollama.MustTool("get_weather", "Return weather conditions in a location", struct { Location string `help:"Location to get weather for" required:""` }{})), @@ -79,16 +81,15 @@ func Test_chat_003(t *testing.T) { t.FailNow() } - // Delete model - client.DeleteModel(context.TODO(), "llava") - // Pull the model - if err := client.PullModel(context.TODO(), "llava", ollama.WithPullStatus(func(status *ollama.PullStatus) { + model, err := client.PullModel(context.TODO(), "llava", ollama.WithPullStatus(func(status *ollama.PullStatus) { t.Log(status) - })); err != nil { + })) + if err != nil { t.FailNow() } + // Explain the content of an image t.Run("Image", func(t *testing.T) { assert := assert.New(t) @@ -98,8 +99,8 @@ func Test_chat_003(t *testing.T) { } defer f.Close() - response, err := client.Chat(context.TODO(), "llava", - client.UserPrompt("where was this photo taken?", ollama.WithData(f)), + response, err := client.Chat(context.TODO(), + model.MustUserPrompt("describe this photo to me", ollama.WithData(f)), ) if !assert.NoError(err) { t.FailNow() diff --git a/pkg/ollama/context.go b/pkg/ollama/context.go deleted file mode 100644 index 1fe5935..0000000 --- a/pkg/ollama/context.go +++ /dev/null @@ -1,100 +0,0 @@ -package ollama - -import ( - "encoding/json" - - // Packages - llm "github.com/mutablelogic/go-llm" -) - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -// Implementation of a message session, which is a sequence of messages -type session struct { - *opt - seq []*MessageMeta -} - -var _ llm.Context = (*session)(nil) - -/////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -func (*model) Context(opts ...llm.Opt) (llm.Context, error) { - // Apply options - opt, err := apply(opts...) - if err != nil { - return nil, err - } - - // Return an empty session - return &session{ - opt: opt, - }, nil -} - -/////////////////////////////////////////////////////////////////////////////// -// STRINGIFY - -func (session session) String() string { - var data []byte - var err error - if len(session.seq) == 1 { - data, err = json.MarshalIndent(session.seq[0], "", " ") - } else { - data, err = json.MarshalIndent(session.seq, "", " ") - } - if err != nil { - return err.Error() - } - return string(data) -} - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// Append user message context, with optional images -func (session *session) AppendUserPrompt(v string, opts ...llm.Opt) error { - // Apply options - opt, err := apply(opts...) - if err != nil { - return nil - } - - var meta MessageMeta - meta.Role = "user" - meta.Content = v - if len(opt.data) > 0 { - meta.Images = make([]Data, len(opt.data)) - copy(meta.Images, opt.data) - } - - // Append the message - session.seq = append(session.seq, &meta) - - // Return success - return nil -} - -// Append the result of a tool call -func (session *session) AppendToolResult(id string, opts ...llm.Opt) error { - // messages.append({'role': 'tool', 'content': str(output), 'name': tool.function.name}) - return nil -} - -// Return the role of the last message -func (session *session) Role() string { - if len(session.seq) == 0 { - return "" - } - return session.seq[len(session.seq)-1].Role -} - -// Return the text of the last message -func (session *session) Text() string { - if len(session.seq) == 0 { - return "" - } - return session.seq[len(session.seq)-1].Content -} diff --git a/pkg/ollama/generate.go b/pkg/ollama/generate.go deleted file mode 100644 index 2097973..0000000 --- a/pkg/ollama/generate.go +++ /dev/null @@ -1,25 +0,0 @@ -package ollama - -import ( - "context" - - // Packages - - llm "github.com/mutablelogic/go-llm" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// Generate a response from a prompt -func (ollama *Client) Generate(ctx context.Context, model llm.Model, prompt llm.Context, opts ...llm.Opt) (llm.Context, error) { - // The prompt should be of type *messages - // Generate a chat response - response, err := ollama.Chat(ctx, model.Name(), prompt, opts...) - if err != nil { - return nil, err - } - - // Return the response - return &session{seq: response.Context}, nil -} diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index eb5be09..85b1cc0 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -7,10 +7,11 @@ package ollama // Chat Message type MessageMeta struct { - Role string `json:"role"` - Content string `json:"content"` - Images []Data `json:"images,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + FunctionName string `json:"name,omitempty"` // Function name for a tool result + Images []Data `json:"images,omitempty"` // Image attachments + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Tool calls from the assistant } // Data represents the raw binary data of an image file. diff --git a/pkg/ollama/model.go b/pkg/ollama/model.go index 62d7492..991393e 100644 --- a/pkg/ollama/model.go +++ b/pkg/ollama/model.go @@ -3,7 +3,6 @@ package ollama import ( "context" "encoding/json" - "fmt" "net/http" "time" @@ -17,6 +16,7 @@ import ( // model is the implementation of the llm.Model interface type model struct { + client *Client ModelMeta } @@ -49,30 +49,6 @@ type ModelDetails struct { // ModelInfo provides additional model parameters type ModelInfo map[string]any -type respListModel struct { - Models []*model `json:"models"` -} - -type reqGetModel struct { - Model string `json:"model"` -} - -type reqCreateModel struct { - Name string `json:"name"` - File string `json:"modelfile"` -} - -type reqPullModel struct { - Model string `json:"model"` - Insecure bool `json:"insecure,omitempty"` - Stream bool `json:"stream"` -} - -type reqCopyModel struct { - Source string `json:"source"` - Destination string `json:"destination"` -} - // PullStatus provides the status of a pull operation in a callback function type PullStatus struct { Status string `json:"status"` @@ -100,6 +76,13 @@ func (m PullStatus) String() string { return string(data) } +/////////////////////////////////////////////////////////////////////////////// +// INTERFACE IMPLEMENTATION + +func (m model) Name() string { + return m.ModelMeta.Name +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -110,6 +93,10 @@ func (ollama *Client) Models(ctx context.Context) ([]llm.Model, error) { // List models func (ollama *Client) ListModels(ctx context.Context) ([]llm.Model, error) { + type respListModel struct { + Models []*model `json:"models"` + } + // Send the request var response respListModel if err := ollama.DoWithContext(ctx, nil, &response, client.OptPath("tags")); err != nil { @@ -119,6 +106,7 @@ func (ollama *Client) ListModels(ctx context.Context) ([]llm.Model, error) { // Convert to llm.Model result := make([]llm.Model, 0, len(response.Models)) for _, model := range response.Models { + model.client = ollama result = append(result, model) } @@ -128,6 +116,10 @@ func (ollama *Client) ListModels(ctx context.Context) ([]llm.Model, error) { // List running models func (ollama *Client) ListRunningModels(ctx context.Context) ([]llm.Model, error) { + type respListModel struct { + Models []*model `json:"models"` + } + // Send the request var response respListModel if err := ollama.DoWithContext(ctx, nil, &response, client.OptPath("ps")); err != nil { @@ -137,6 +129,7 @@ func (ollama *Client) ListRunningModels(ctx context.Context) ([]llm.Model, error // Convert to llm.Model result := make([]llm.Model, 0, len(response.Models)) for _, model := range response.Models { + model.client = ollama result = append(result, model) } @@ -146,6 +139,10 @@ func (ollama *Client) ListRunningModels(ctx context.Context) ([]llm.Model, error // Get model details func (ollama *Client) GetModel(ctx context.Context, name string) (llm.Model, error) { + type reqGetModel struct { + Model string `json:"model"` + } + // Request req, err := client.NewJSONRequest(reqGetModel{ Model: name, @@ -159,6 +156,7 @@ func (ollama *Client) GetModel(ctx context.Context, name string) (llm.Model, err if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("show")); err != nil { return nil, err } else { + response.client = ollama response.ModelMeta.Name = name } @@ -168,6 +166,12 @@ func (ollama *Client) GetModel(ctx context.Context, name string) (llm.Model, err // Copy a local model by name func (ollama *Client) CopyModel(ctx context.Context, source, destination string) error { + type reqCopyModel struct { + Source string `json:"source"` + Destination string `json:"destination"` + } + + // Request req, err := client.NewJSONRequest(reqCopyModel{ Source: source, Destination: destination, @@ -176,12 +180,17 @@ func (ollama *Client) CopyModel(ctx context.Context, source, destination string) return err } - // Send the request + // Response return ollama.Do(req, nil, client.OptPath("copy")) } // Delete a local model by name func (ollama *Client) DeleteModel(ctx context.Context, name string) error { + type reqGetModel struct { + Model string `json:"model"` + } + + // Request req, err := client.NewJSONRequestEx(http.MethodDelete, reqGetModel{ Model: name, }, client.ContentTypeAny) @@ -189,16 +198,22 @@ func (ollama *Client) DeleteModel(ctx context.Context, name string) error { return err } - // Send the request + // Response return ollama.Do(req, nil, client.OptPath("delete")) } // Pull a remote model locally -func (c *Client) PullModel(ctx context.Context, name string, opts ...llm.Opt) error { +func (ollama *Client) PullModel(ctx context.Context, name string, opts ...llm.Opt) (llm.Model, error) { + type reqPullModel struct { + Model string `json:"model"` + Insecure bool `json:"insecure,omitempty"` + Stream bool `json:"stream"` + } + // Apply options opt, err := apply(opts...) if err != nil { - return err + return nil, err } // Request @@ -208,32 +223,20 @@ func (c *Client) PullModel(ctx context.Context, name string, opts ...llm.Opt) er Insecure: opt.insecure, }) if err != nil { - return err + return nil, err } // Response var response PullStatus - if err := c.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout(), client.OptJsonStreamCallback(func(v any) error { + if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout(), client.OptJsonStreamCallback(func(v any) error { if v, ok := v.(*PullStatus); ok && opt.pullcallback != nil { opt.pullcallback(v) } return nil })); err != nil { - return err - } - - // Check status - if response.Status != "success" { - return fmt.Errorf("Pull failed: %v", response.Status) + return nil, err } // Return success - return nil -} - -/////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -func (m model) Name() string { - return m.ModelMeta.Name + return ollama.GetModel(ctx, name) } diff --git a/pkg/ollama/model_test.go b/pkg/ollama/model_test.go index c19919d..db14c9d 100644 --- a/pkg/ollama/model_test.go +++ b/pkg/ollama/model_test.go @@ -43,12 +43,13 @@ func Test_model_001(t *testing.T) { t.Run("PullModel", func(t *testing.T) { assert := assert.New(t) - err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { + model, err := client.PullModel(context.TODO(), "qwen:0.5b", ollama.WithPullStatus(func(status *ollama.PullStatus) { t.Log(status) })) if !assert.NoError(err) { t.FailNow() } + assert.NotNil(model) }) t.Run("CopyModel", func(t2 *testing.T) { diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go new file mode 100644 index 0000000..b9e5273 --- /dev/null +++ b/pkg/ollama/session.go @@ -0,0 +1,176 @@ +package ollama + +import ( + "context" + "encoding/json" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Implementation of a message session, which is a sequence of messages +type session struct { + opts []llm.Opt + model *model + seq []*MessageMeta +} + +var _ llm.Context = (*session)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create a new empty context +func (model *model) Context(opts ...llm.Opt) (llm.Context, error) { + return &session{ + model: model, + opts: opts, + }, nil +} + +// Create a new context with a user prompt +func (model *model) MustUserPrompt(prompt string, opts ...llm.Opt) llm.Context { + context, err := model.Context(opts...) + if err != nil { + panic(err) + } + context.(*session).seq = append(context.(*session).seq, &MessageMeta{ + Role: "user", + Content: prompt, + }) + return context +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (session session) String() string { + var data []byte + var err error + if len(session.seq) == 1 { + data, err = json.MarshalIndent(session.seq[0], "", " ") + } else { + data, err = json.MarshalIndent(session.seq, "", " ") + } + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Generate a response from a user prompt (with attachments) +func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) (llm.Context, error) { + // Make a new session + response := new(session) + response.model = s.model + response.opts = s.opts + response.seq = make([]*MessageMeta, len(s.seq)+1, len(s.seq)+2) + + // Append the user prompt + if user, err := userPrompt(prompt, opts...); err != nil { + return nil, err + } else { + response.seq[len(response.seq)-1] = user + } + + // Call the 'chat' method + client := s.model.client + r, err := client.Chat(ctx, response, response.opts...) + if err != nil { + return nil, err + } else { + response.seq = append(response.seq, &r.Message) + } + + // Return success + return response, nil +} + +// Generate a response from a tool calling result +func (s *session) FromTool(ctx context.Context, call string, result any) (llm.Context, error) { + // Make a new session + response := new(session) + response.model = s.model + response.opts = s.opts + response.seq = make([]*MessageMeta, len(s.seq)+1, len(s.seq)+2) + + // Append the tool result + if message, err := toolResult(call, result); err != nil { + return nil, err + } else { + response.seq[len(response.seq)-1] = message + } + + // Call the 'chat' method + r, err := s.model.client.Chat(ctx, response, response.opts...) + if err != nil { + return nil, err + } else { + response.seq = append(response.seq, &r.Message) + } + + // Return success + return response, nil +} + +// Return the role of the last message +func (session *session) Role() string { + if len(session.seq) == 0 { + return "" + } + return session.seq[len(session.seq)-1].Role +} + +// Return the text of the last message +func (session *session) Text() string { + if len(session.seq) == 0 { + return "" + } + return session.seq[len(session.seq)-1].Content +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { + // Apply options + opt, err := apply(opts...) + if err != nil { + return nil, err + } + + // Create a new message + var meta MessageMeta + meta.Role = "user" + meta.Content = prompt + if len(opt.data) > 0 { + meta.Images = make([]Data, len(opt.data)) + copy(meta.Images, opt.data) + } + + // Return success + return &meta, nil +} + +func toolResult(name string, result any) (*MessageMeta, error) { + // Turn result into JSON + data, err := json.Marshal(result) + if err != nil { + return nil, err + } + + // Create a new message + var meta MessageMeta + meta.Role = "tool" + meta.FunctionName = name + meta.Content = string(data) + + // Return success + return &meta, nil +} diff --git a/pkg/ollama/session_test.go b/pkg/ollama/session_test.go new file mode 100644 index 0000000..23ad110 --- /dev/null +++ b/pkg/ollama/session_test.go @@ -0,0 +1,89 @@ +package ollama_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + ollama "github.com/mutablelogic/go-llm/pkg/ollama" + assert "github.com/stretchr/testify/assert" +) + +func Test_session_001(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + // Pull the model + model, err := client.PullModel(context.TODO(), "qwen:0.5b") + if err != nil { + t.FailNow() + } + + // Session with a single user prompt - streaming + t.Run("stream", func(t *testing.T) { + assert := assert.New(t) + session, err := model.Context(ollama.WithStream(func(stream *ollama.Response) { + t.Log("SESSION DELTA", stream) + })) + assert.NotNil(session) + + new_session, err := session.FromUser(context.TODO(), "Why is the grass green?") + if !assert.NoError(err) { + t.FailNow() + } + assert.Equal("assistant", new_session.Role()) + assert.NotEmpty(new_session.Text()) + }) + + // Session with a single user prompt - not streaming + t.Run("nostream", func(t *testing.T) { + assert := assert.New(t) + session, err := model.Context() + assert.NotNil(session) + + new_session, err := session.FromUser(context.TODO(), "Why is the sky blue?") + if !assert.NoError(err) { + t.FailNow() + } + assert.Equal("assistant", new_session.Role()) + assert.NotEmpty(new_session.Text()) + }) +} + +func Test_session_002(t *testing.T) { + client, err := ollama.New(GetEndpoint(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + // Pull the model + model, err := client.PullModel(context.TODO(), "llama3.2") + if err != nil { + t.FailNow() + } + + // Session with a tool call + t.Run("toolcall", func(t *testing.T) { + assert := assert.New(t) + + tool, err := ollama.NewTool("get_weather", "Return the current weather", nil) + if !assert.NoError(err) { + t.FailNow() + } + + session, err := model.Context(ollama.WithTool(tool)) + if !assert.NoError(err) { + t.FailNow() + } + assert.NotNil(session) + new_session, err := session.FromUser(context.TODO(), "What is today's weather?") + if !assert.NoError(err) { + t.FailNow() + } + t.Log(new_session) + }) +} diff --git a/pkg/ollama/tool.go b/pkg/ollama/tool.go index 92d244c..4d85ba6 100644 --- a/pkg/ollama/tool.go +++ b/pkg/ollama/tool.go @@ -134,6 +134,9 @@ func (t *Tool) Params(call ToolCall) (any, error) { // Return tool parameters from a struct func paramsFor(params any) ([]ToolParameter, error) { + if params == nil { + return []ToolParameter{}, nil + } rt := reflect.TypeOf(params) if rt.Kind() == reflect.Ptr { rt = rt.Elem() From d6dd058883d7c6dd57e2a11d45c83abfc7db8dbd Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 13:37:18 +0100 Subject: [PATCH 19/33] Updated --- context.go | 4 ++-- pkg/ollama/session.go | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/context.go b/context.go index e98ffa6..571c680 100644 --- a/context.go +++ b/context.go @@ -16,6 +16,6 @@ type Context interface { // Generate a response from a user prompt (with attachments) FromUser(context.Context, string, ...Opt) (Context, error) - // Generate a response from a tool, passing the call identifier or funtion name, and the result - FromTool(context.Context, string, any) (Context, error) + // Generate a response from a tool, passing the call identifier or function name, and the result + FromTool(context.Context, string, any, ...Opt) (Context, error) } diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index b9e5273..0646be5 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -79,9 +79,14 @@ func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) response.seq[len(response.seq)-1] = user } + // The options come from the session options and the user options + chatopts := make([]llm.Opt, 0, len(s.opts)+len(opts)) + chatopts = append(chatopts, s.opts...) + chatopts = append(chatopts, opts...) + // Call the 'chat' method client := s.model.client - r, err := client.Chat(ctx, response, response.opts...) + r, err := client.Chat(ctx, response, chatopts...) if err != nil { return nil, err } else { @@ -93,7 +98,7 @@ func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) } // Generate a response from a tool calling result -func (s *session) FromTool(ctx context.Context, call string, result any) (llm.Context, error) { +func (s *session) FromTool(ctx context.Context, call string, result any, opts ...llm.Opt) (llm.Context, error) { // Make a new session response := new(session) response.model = s.model @@ -107,8 +112,13 @@ func (s *session) FromTool(ctx context.Context, call string, result any) (llm.Co response.seq[len(response.seq)-1] = message } + // The options come from the session options and the user options + chatopts := make([]llm.Opt, 0, len(s.opts)+len(opts)) + chatopts = append(chatopts, s.opts...) + chatopts = append(chatopts, opts...) + // Call the 'chat' method - r, err := s.model.client.Chat(ctx, response, response.opts...) + r, err := s.model.client.Chat(ctx, response, chatopts...) if err != nil { return nil, err } else { From 98638d2efad32cbed8e1dca99c4f9ca107471571 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 13:55:23 +0100 Subject: [PATCH 20/33] Updates --- context.go | 6 +- model.go | 6 +- pkg/anthropic/messages.go | 16 +---- pkg/anthropic/messages_test.go | 10 ++-- pkg/anthropic/{context.go => session.go} | 76 ++++++++++++++---------- pkg/ollama/chat_test.go | 8 +-- pkg/ollama/session.go | 11 ++-- pkg/ollama/session_test.go | 9 +-- 8 files changed, 72 insertions(+), 70 deletions(-) rename pkg/anthropic/{context.go => session.go} (62%) diff --git a/context.go b/context.go index 571c680..063475e 100644 --- a/context.go +++ b/context.go @@ -13,9 +13,11 @@ type Context interface { // Return the text of the context Text() string - // Generate a response from a user prompt (with attachments) + // Generate a response from a user prompt (with attachments and + // other empheral options FromUser(context.Context, string, ...Opt) (Context, error) - // Generate a response from a tool, passing the call identifier or function name, and the result + // Generate a response from a tool, passing the call identifier or + // function name, and the result FromTool(context.Context, string, any, ...Opt) (Context, error) } diff --git a/model.go b/model.go index 9ee6bd0..49eac35 100644 --- a/model.go +++ b/model.go @@ -9,9 +9,9 @@ type Model interface { // Return am empty session context object for the model, // setting session options - Context(...Opt) (Context, error) + Context(...Opt) Context // Convenience method to create a session context object - // with a user prompt, which panics on error - MustUserPrompt(string, ...Opt) Context + // with a user prompt + UserPrompt(string, ...Opt) Context } diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 8c74ee2..95e593a 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -61,7 +61,7 @@ type reqMessages struct { opt } -func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (*Response, error) { +func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts ...llm.Opt) (*Response, error) { // Apply options opt, err := apply(opts...) if err != nil { @@ -70,12 +70,12 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context // Set max_tokens if opt.MaxTokens == 0 { - opt.MaxTokens = defaultMaxTokens(model.Name()) + opt.MaxTokens = defaultMaxTokens(context.(*session).model.Name()) } // Request req, err := client.NewJSONRequest(reqMessages{ - Model: model.Name(), + Model: context.(*session).model.Name(), Messages: context.(*session).seq, opt: *opt, }) @@ -222,16 +222,6 @@ func (anthropic *Client) Messages(ctx context.Context, model llm.Model, context return &response, nil } -// Generate a response from a prompt -func (anthropic *Client) Generate(ctx context.Context, model llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { - response, err := anthropic.Messages(ctx, model, context, opts...) - if err != nil { - return nil, err - } - fmt.Println(response) - return nil, llm.ErrNotImplemented -} - /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index ae19b32..90280be 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -33,7 +33,7 @@ func Test_messages_001(t *testing.T) { } defer f.Close() - response, err := client.Messages(context.TODO(), model, client.UserPrompt("what is this image?", anthropic.WithData(f, false, false))) + response, err := client.Messages(context.TODO(), model.UserPrompt("what is this image?", anthropic.WithData(f, false, false))) if assert.NoError(err) { t.Log(response) } @@ -61,7 +61,7 @@ func Test_messages_002(t *testing.T) { } defer f.Close() - response, err := client.Messages(context.TODO(), model, client.UserPrompt("summarize this document for me", anthropic.WithData(f, false, false))) + response, err := client.Messages(context.TODO(), model.UserPrompt("summarize this document for me", anthropic.WithData(f, false, false))) if assert.NoError(err) { t.Log(response) } @@ -83,7 +83,7 @@ func Test_messages_003(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { t.Log(r) })) if assert.NoError(err) { @@ -114,7 +114,7 @@ func Test_messages_004(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithTool(weather)) + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithTool(weather)) if assert.NoError(err) { t.Log(response) } @@ -143,7 +143,7 @@ func Test_messages_005(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model, client.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { t.Log(r) }), anthropic.WithTool(weather)) if assert.NoError(err) { diff --git a/pkg/anthropic/context.go b/pkg/anthropic/session.go similarity index 62% rename from pkg/anthropic/context.go rename to pkg/anthropic/session.go index 8564da8..735dfce 100644 --- a/pkg/anthropic/context.go +++ b/pkg/anthropic/session.go @@ -1,8 +1,10 @@ package anthropic import ( + "context" "encoding/json" + // Packages llm "github.com/mutablelogic/go-llm" ) @@ -10,7 +12,9 @@ import ( // TYPES type session struct { - seq []*MessageMeta + model *model + opts []llm.Opt + seq []*MessageMeta } var _ llm.Context = (*session)(nil) @@ -18,9 +22,39 @@ var _ llm.Context = (*session)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -func (*model) Context(...llm.Opt) (llm.Context, error) { - // TODO: Currently ignoring options - return &session{}, nil +// Return am empty session context object for the model, +// setting session options +func (model *model) Context(opts ...llm.Opt) llm.Context { + return &session{ + model: model, + opts: opts, + } +} + +// Convenience method to create a session context object +// with a user prompt, which panics on error +func (model *model) UserPrompt(prompt string, opts ...llm.Opt) llm.Context { + // Apply attachments + opt, err := apply(opts...) + if err != nil { + panic(err) + } + + meta := MessageMeta{ + Role: "user", + Content: make([]*Content, 1, len(opt.data)+1), + } + + // Append the text + meta.Content[0] = NewTextContent(prompt) + + // Append any additional data + for _, data := range opt.data { + meta.Content = append(meta.Content, data) + } + + // Return success + return nil } /////////////////////////////////////////////////////////////////////////////// @@ -64,32 +98,14 @@ func (session *session) Text() string { return string(data) } -// Append user prompt (and attachments) to a context -func (session *session) AppendUserPrompt(text string, opts ...llm.Opt) error { - // Apply attachments - opt, err := apply(opts...) - if err != nil { - return err - } - - meta := MessageMeta{ - Role: "user", - Content: make([]*Content, 1, len(opt.data)+1), - } - - // Append the text - meta.Content[0] = NewTextContent(text) - - // Append any additional data - for _, data := range opt.data { - meta.Content = append(meta.Content, data) - } - - // Return success - return nil +// Generate a response from a user prompt (with attachments and +// other empheral options +func (session *session) FromUser(context.Context, string, ...llm.Opt) (llm.Context, error) { + return nil, llm.ErrNotImplemented } -// Append the result of calling a tool to a context -func (session *session) AppendToolResult(string, ...llm.Opt) error { - return llm.ErrNotImplemented +// Generate a response from a tool, passing the call identifier or +// function name, and the result +func (session *session) FromTool(context.Context, string, any, ...llm.Opt) (llm.Context, error) { + return nil, llm.ErrNotImplemented } diff --git a/pkg/ollama/chat_test.go b/pkg/ollama/chat_test.go index c5f6545..ddbd5b7 100644 --- a/pkg/ollama/chat_test.go +++ b/pkg/ollama/chat_test.go @@ -27,7 +27,7 @@ func Test_chat_001(t *testing.T) { t.Run("ChatStream", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), model.MustUserPrompt("why is the sky blue?"), ollama.WithStream(func(stream *ollama.Response) { + response, err := client.Chat(context.TODO(), model.UserPrompt("why is the sky blue?"), ollama.WithStream(func(stream *ollama.Response) { t.Log(stream) })) if !assert.NoError(err) { @@ -38,7 +38,7 @@ func Test_chat_001(t *testing.T) { t.Run("ChatNoStream", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), model.MustUserPrompt("why is the sky green?")) + response, err := client.Chat(context.TODO(), model.UserPrompt("why is the sky green?")) if !assert.NoError(err) { t.FailNow() } @@ -63,7 +63,7 @@ func Test_chat_002(t *testing.T) { t.Run("Tools", func(t *testing.T) { assert := assert.New(t) response, err := client.Chat(context.TODO(), - model.MustUserPrompt("what is the weather in berlin?"), + model.UserPrompt("what is the weather in berlin?"), ollama.WithTool(ollama.MustTool("get_weather", "Return weather conditions in a location", struct { Location string `help:"Location to get weather for" required:""` }{})), @@ -100,7 +100,7 @@ func Test_chat_003(t *testing.T) { defer f.Close() response, err := client.Chat(context.TODO(), - model.MustUserPrompt("describe this photo to me", ollama.WithData(f)), + model.UserPrompt("describe this photo to me", ollama.WithData(f)), ) if !assert.NoError(err) { t.FailNow() diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 0646be5..7c21bdb 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -24,19 +24,16 @@ var _ llm.Context = (*session)(nil) // LIFECYCLE // Create a new empty context -func (model *model) Context(opts ...llm.Opt) (llm.Context, error) { +func (model *model) Context(opts ...llm.Opt) llm.Context { return &session{ model: model, opts: opts, - }, nil + } } // Create a new context with a user prompt -func (model *model) MustUserPrompt(prompt string, opts ...llm.Opt) llm.Context { - context, err := model.Context(opts...) - if err != nil { - panic(err) - } +func (model *model) UserPrompt(prompt string, opts ...llm.Opt) llm.Context { + context := model.Context(opts...) context.(*session).seq = append(context.(*session).seq, &MessageMeta{ Role: "user", Content: prompt, diff --git a/pkg/ollama/session_test.go b/pkg/ollama/session_test.go index 23ad110..a22bbab 100644 --- a/pkg/ollama/session_test.go +++ b/pkg/ollama/session_test.go @@ -26,7 +26,7 @@ func Test_session_001(t *testing.T) { // Session with a single user prompt - streaming t.Run("stream", func(t *testing.T) { assert := assert.New(t) - session, err := model.Context(ollama.WithStream(func(stream *ollama.Response) { + session := model.Context(ollama.WithStream(func(stream *ollama.Response) { t.Log("SESSION DELTA", stream) })) assert.NotNil(session) @@ -42,7 +42,7 @@ func Test_session_001(t *testing.T) { // Session with a single user prompt - not streaming t.Run("nostream", func(t *testing.T) { assert := assert.New(t) - session, err := model.Context() + session := model.Context() assert.NotNil(session) new_session, err := session.FromUser(context.TODO(), "Why is the sky blue?") @@ -75,10 +75,7 @@ func Test_session_002(t *testing.T) { t.FailNow() } - session, err := model.Context(ollama.WithTool(tool)) - if !assert.NoError(err) { - t.FailNow() - } + session := model.Context(ollama.WithTool(tool)) assert.NotNil(session) new_session, err := session.FromUser(context.TODO(), "What is today's weather?") if !assert.NoError(err) { From 0d1dadd08a9171328e39338db4fc95f5a5ad3686 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 14:40:20 +0100 Subject: [PATCH 21/33] Added generate code --- cmd/agent/generate.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go index b4e11a1..962bb4a 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/generate.go @@ -26,17 +26,17 @@ type GenerateCmd struct { func (cmd *GenerateCmd) Run(globals *Globals) error { return runagent(globals, func(ctx context.Context, client llm.Agent) error { // Get the model - agent, ok := client.(*agent.Agent) + a, ok := client.(*agent.Agent) if !ok { return fmt.Errorf("No agents found") } - model, err := agent.GetModel(ctx, cmd.Model) + model, err := a.GetModel(ctx, cmd.Model) if err != nil { return err } // Create a session - session, err := model.Context(agent.WithStream(!cmd.NoStream)) + session := model.Context(agent.WithStream(!cmd.NoStream)) if err != nil { return err } @@ -48,21 +48,23 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { return nil } else if err != nil { return err - } else if err := session.AppendUserPrompt(strings.TrimSpace(input)); err != nil { - return err } - // Ignore empty import - if session.Text() == "" { + // Ignore empty input + input = strings.TrimSpace(input) + if input == "" { continue } // Feed input into the model - response, err := agent.Generate(ctx, model, session) + response, err := session.FromUser(ctx, input) if err != nil { return err } fmt.Println(response.Text()) + + // Update session + session = response } }) } From 8b1151e7a04d8e0dba09cb7f73c4770b97fbdf0e Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 17:00:02 +0100 Subject: [PATCH 22/33] Tool migration --- .DS_Store | Bin 0 -> 6148 bytes context.go | 11 +- pkg/anthropic/messages.go | 2 + pkg/anthropic/model.go | 5 +- pkg/anthropic/opt.go | 20 +++- pkg/anthropic/session.go | 109 +++++++++++++++---- pkg/anthropic/session_test.go | 88 +++++++++++++++ pkg/anthropic/{tool.go => tool.go_old} | 39 +++++++ pkg/ollama/message.go | 2 - pkg/ollama/session.go | 59 +++++----- pkg/ollama/session_test.go | 17 +-- pkg/ollama/tool.go | 5 + pkg/tool/tollcall.go | 9 ++ pkg/tool/tool.go | 145 +++++++++++++++++++++++++ pkg/tool/toolkit.go | 86 +++++++++++++++ response.go | 29 ----- tool.go | 22 ++-- 17 files changed, 542 insertions(+), 106 deletions(-) create mode 100644 .DS_Store create mode 100644 pkg/anthropic/session_test.go rename pkg/anthropic/{tool.go => tool.go_old} (84%) create mode 100644 pkg/tool/tollcall.go create mode 100644 pkg/tool/tool.go create mode 100644 pkg/tool/toolkit.go delete mode 100644 response.go diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0a310b86d24da91bbbf5a4a44af2465bc821af04 GIT binary patch literal 6148 zcmeHK%}T>S5Z<-bBorYBg&r5Y7ObUI#Y>3w1&ruHr6#1*(3mYvY7eE5v%Zi|;`2DO zy8(+ii`W_1{pNQ!`$6`HF~ZQG6L%(nwi*N z2mJO1o3SaIM9c5rpC)a5r8h|}I+-4UyE)cb?} zdhOVUN5>b}qiOn*$u~_W2ey^$8?4|R6tkMwV4kHin}DavuCfS;0b+m{AO^OZ0dp?c zt?j0PR! z<=_`4&ofwR)a8t;nPD6=bM<)PYIg7omCm@Qk$Pf)7}#W>p-mUh|10=qY9INVC1eo; z#K1pefH#N1(1%5tv-QXF@T?Wko}i&%UWp0_=o^;+FmNAftDuex)FIC^SZc&k(67n? P=^~&Ap^g~%1qQwVOKVAc literal 0 HcmV?d00001 diff --git a/context.go b/context.go index 063475e..e391f15 100644 --- a/context.go +++ b/context.go @@ -7,17 +7,20 @@ import "context" // Context is fed to the agent to generate a response type Context interface { - // Return the role, which can be system, assistant, user, tool, tool_result, ... + // Return the current session role, which can be system, assistant, user, tool, tool_result, ... Role() string - // Return the text of the context + // Return the current session text, or empty string if no text was returned Text() string + // Return the current session tool calls, or empty if no tool calls were made + ToolCalls() []ToolCall + // Generate a response from a user prompt (with attachments and // other empheral options - FromUser(context.Context, string, ...Opt) (Context, error) + FromUser(context.Context, string, ...Opt) error // Generate a response from a tool, passing the call identifier or // function name, and the result - FromTool(context.Context, string, any, ...Opt) (Context, error) + FromTool(context.Context, string, any, ...Opt) error } diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 95e593a..10326e2 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -58,6 +58,7 @@ func (r opt) String() string { type reqMessages struct { Model string `json:"model"` Messages []*MessageMeta `json:"messages"` + Tools []llm.Tool `json:"tools,omitempty"` opt } @@ -77,6 +78,7 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts req, err := client.NewJSONRequest(reqMessages{ Model: context.(*session).model.Name(), Messages: context.(*session).seq, + Tools: opt.Tools(), opt: *opt, }) if err != nil { diff --git a/pkg/anthropic/model.go b/pkg/anthropic/model.go index ff94151..7b7511f 100644 --- a/pkg/anthropic/model.go +++ b/pkg/anthropic/model.go @@ -15,6 +15,7 @@ import ( // model is the implementation of the llm.Model interface type model struct { + client *Client ModelMeta } @@ -38,14 +39,13 @@ func (anthropic *Client) Models(ctx context.Context) ([]llm.Model, error) { // Get a model by name func (anthropic *Client) GetModel(ctx context.Context, name string) (llm.Model, error) { - var response ModelMeta if err := anthropic.DoWithContext(ctx, nil, &response, client.OptPath("models", name)); err != nil { return nil, err } // Return success - return &model{ModelMeta: response}, nil + return &model{client: anthropic, ModelMeta: response}, nil } // List models @@ -68,6 +68,7 @@ func (anthropic *Client) ListModels(ctx context.Context) ([]llm.Model, error) { // Convert to llm.Model for _, meta := range response.Body { result = append(result, &model{ + client: anthropic, ModelMeta: meta, }) } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 77d1d66..3e72a61 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -5,6 +5,7 @@ import ( // Packages llm "github.com/mutablelogic/go-llm" + tool "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// @@ -19,10 +20,10 @@ type opt struct { Temperature float64 `json:"temperature,omitempty"` TopK uint `json:"top_k,omitempty"` TopP float64 `json:"top_p,omitempty"` - Tools []*Tool `json:"tools,omitempty"` data []*Content // Additional message content callback func(*Response) // Streaming callback + toolkit *tool.ToolKit // Toolkit for tools } type optmetadata struct { @@ -42,6 +43,17 @@ func apply(opts ...llm.Opt) (*opt, error) { return o, nil } +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (o *opt) Tools() []llm.Tool { + if o.toolkit == nil { + return nil + } else { + return o.toolkit.Tools() + } +} + //////////////////////////////////////////////////////////////////////////////// // OPTIONS @@ -122,11 +134,11 @@ func WithTopK(v uint) llm.Opt { } } -// Messages: Append a tool to the request. -func WithTool(v *Tool) llm.Opt { +// Messages: Append a toolkit to the request +func WithToolKit(v *tool.ToolKit) llm.Opt { return func(o any) error { if v != nil { - o.(*opt).Tools = append(o.(*opt).Tools, v) + o.(*opt).toolkit = v } return nil } diff --git a/pkg/anthropic/session.go b/pkg/anthropic/session.go index 735dfce..32c20cb 100644 --- a/pkg/anthropic/session.go +++ b/pkg/anthropic/session.go @@ -22,8 +22,7 @@ var _ llm.Context = (*session)(nil) /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// Return am empty session context object for the model, -// setting session options +// Return an empty session context object for the model, setting session options func (model *model) Context(opts ...llm.Opt) llm.Context { return &session{ model: model, @@ -31,30 +30,21 @@ func (model *model) Context(opts ...llm.Opt) llm.Context { } } -// Convenience method to create a session context object -// with a user prompt, which panics on error +// Convenience method to create a session context object with a user prompt, which +// panics on error func (model *model) UserPrompt(prompt string, opts ...llm.Opt) llm.Context { - // Apply attachments - opt, err := apply(opts...) + context := model.Context(opts...) + + meta, err := userPrompt(prompt, opts...) if err != nil { panic(err) } - meta := MessageMeta{ - Role: "user", - Content: make([]*Content, 1, len(opt.data)+1), - } - - // Append the text - meta.Content[0] = NewTextContent(prompt) - - // Append any additional data - for _, data := range opt.data { - meta.Content = append(meta.Content, data) - } + // Add to the sequence + context.(*session).seq = append(context.(*session).seq, meta) // Return success - return nil + return context } /////////////////////////////////////////////////////////////////////////////// @@ -98,14 +88,85 @@ func (session *session) Text() string { return string(data) } -// Generate a response from a user prompt (with attachments and +// Return the current session tool calls, or empty if no tool calls were made +func (session *session) ToolCalls() []llm.ToolCall { + // Sanity check for tool call + if len(session.seq) == 0 { + return nil + } + meta := session.seq[len(session.seq)-1] + if meta.Role != "assistant" { + return nil + } + + // Gather tool calls + var result []llm.ToolCall + for _, content := range meta.Content { + if content.Type == "tool_use" { + result = append(result, NewToolCall(content)) + } + } + return result +} + +// Generate a response from a user prompt (with attachments) and // other empheral options -func (session *session) FromUser(context.Context, string, ...llm.Opt) (llm.Context, error) { - return nil, llm.ErrNotImplemented +func (session *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) error { + // Append the user prompt to the sequence + meta, err := userPrompt(prompt, opts...) + if err != nil { + return err + } else { + session.seq = append(session.seq, meta) + } + + // The options come from the session options and the user options + chatopts := make([]llm.Opt, 0, len(session.opts)+len(opts)) + chatopts = append(chatopts, session.opts...) + chatopts = append(chatopts, opts...) + + // Call the 'chat' method + client := session.model.client + r, err := client.Messages(ctx, session, chatopts...) + if err != nil { + return err + } else { + session.seq = append(session.seq, &r.MessageMeta) + } + + // Return success + return nil } // Generate a response from a tool, passing the call identifier or // function name, and the result -func (session *session) FromTool(context.Context, string, any, ...llm.Opt) (llm.Context, error) { - return nil, llm.ErrNotImplemented +func (session *session) FromTool(context.Context, string, any, ...llm.Opt) error { + return llm.ErrNotImplemented +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { + // Apply attachments + opt, err := apply(opts...) + if err != nil { + return nil, err + } + + meta := MessageMeta{ + Role: "user", + Content: make([]*Content, 1, len(opt.data)+1), + } + + // Append the text + meta.Content[0] = NewTextContent(prompt) + + // Append any additional data + for _, data := range opt.data { + meta.Content = append(meta.Content, data) + } + + // Return success + return &meta, nil } diff --git a/pkg/anthropic/session_test.go b/pkg/anthropic/session_test.go new file mode 100644 index 0000000..2dff9ac --- /dev/null +++ b/pkg/anthropic/session_test.go @@ -0,0 +1,88 @@ +package anthropic_test + +import ( + "context" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + assert "github.com/stretchr/testify/assert" +) + +func Test_session_001(t *testing.T) { + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if err != nil { + t.FailNow() + } + + // Session with a single user prompt - streaming + t.Run("stream", func(t *testing.T) { + assert := assert.New(t) + session := model.Context(anthropic.WithStream(func(stream *anthropic.Response) { + t.Log("SESSION DELTA", stream) + })) + assert.NotNil(session) + + err := session.FromUser(context.TODO(), "Why is the grass green?") + if !assert.NoError(err) { + t.FailNow() + } + assert.Equal("assistant", session.Role()) + assert.NotEmpty(session.Text()) + }) + + // Session with a single user prompt - not streaming + t.Run("nostream", func(t *testing.T) { + assert := assert.New(t) + session := model.Context() + assert.NotNil(session) + + err := session.FromUser(context.TODO(), "Why is the sky blue?") + if !assert.NoError(err) { + t.FailNow() + } + assert.Equal("assistant", session.Role()) + assert.NotEmpty(session.Text()) + }) +} + +func Test_session_002(t *testing.T) { + client, err := anthropic.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + if err != nil { + t.FailNow() + } + + model, err := client.GetModel(context.TODO(), "claude-3-haiku-20240307") + if err != nil { + t.FailNow() + } + + // Session with a tool call + t.Run("toolcall", func(t *testing.T) { + assert := assert.New(t) + + tool, err := anthropic.NewTool("get_weather", "Return the current weather", nil) + if !assert.NoError(err) { + t.FailNow() + } + + session := model.Context(anthropic.WithTool(tool)) + assert.NotNil(session) + + err = session.FromUser(context.TODO(), "What is today's weather?") + if !assert.NoError(err) { + t.FailNow() + } + + toolcalls := session.ToolCalls() + assert.NotEmpty(toolcalls) + t.Log(toolcalls) + }) +} diff --git a/pkg/anthropic/tool.go b/pkg/anthropic/tool.go_old similarity index 84% rename from pkg/anthropic/tool.go rename to pkg/anthropic/tool.go_old index a343aa5..2d0a24b 100644 --- a/pkg/anthropic/tool.go +++ b/pkg/anthropic/tool.go_old @@ -32,6 +32,10 @@ type ToolParameter struct { index []int // Field index into prototype for setting a field } +type toolcall struct { + ContentTool +} + /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE @@ -77,6 +81,14 @@ func NewTool(name, description string, params any) (*Tool, error) { return &tool, nil } +// Return a new tool call from a content parameter +func NewToolCall(content *Content) *toolcall { + if content == nil || content.ContentTool.Id == "" || content.ContentTool.Name == "" { + return nil + } + return &toolcall{content.ContentTool} +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY @@ -88,11 +100,38 @@ func (t Tool) String() string { return string(data) } +func (t toolcall) String() string { + data, err := json.MarshalIndent(t, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (t *toolcall) Name() string { + return t.ContentTool.Name +} + +func (t *toolcall) Id() string { + return t.ContentTool.Id +} + +func (t *toolcall) Params() any { + // TODO: Convert + return t.ContentTool.Input +} + /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS // Return tool parameters from a struct func paramsFor(params any) ([]ToolParameter, error) { + if params == nil { + return []ToolParameter{}, nil + } rt := reflect.TypeOf(params) if rt.Kind() == reflect.Ptr { rt = rt.Elem() diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index 85b1cc0..61c9b42 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -1,7 +1,5 @@ package ollama -// Packages - /////////////////////////////////////////////////////////////////////////////// // TYPES diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 7c21bdb..234538d 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -62,18 +62,12 @@ func (session session) String() string { // PUBLIC METHODS // Generate a response from a user prompt (with attachments) -func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) (llm.Context, error) { - // Make a new session - response := new(session) - response.model = s.model - response.opts = s.opts - response.seq = make([]*MessageMeta, len(s.seq)+1, len(s.seq)+2) - +func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) error { // Append the user prompt if user, err := userPrompt(prompt, opts...); err != nil { - return nil, err + return err } else { - response.seq[len(response.seq)-1] = user + s.seq = append(s.seq, user) } // The options come from the session options and the user options @@ -83,30 +77,24 @@ func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) // Call the 'chat' method client := s.model.client - r, err := client.Chat(ctx, response, chatopts...) + r, err := client.Chat(ctx, s, chatopts...) if err != nil { - return nil, err + return err } else { - response.seq = append(response.seq, &r.Message) + s.seq = append(s.seq, &r.Message) } // Return success - return response, nil + return nil } // Generate a response from a tool calling result -func (s *session) FromTool(ctx context.Context, call string, result any, opts ...llm.Opt) (llm.Context, error) { - // Make a new session - response := new(session) - response.model = s.model - response.opts = s.opts - response.seq = make([]*MessageMeta, len(s.seq)+1, len(s.seq)+2) - +func (s *session) FromTool(ctx context.Context, call string, result any, opts ...llm.Opt) error { // Append the tool result if message, err := toolResult(call, result); err != nil { - return nil, err + return err } else { - response.seq[len(response.seq)-1] = message + s.seq[len(s.seq)-1] = message } // The options come from the session options and the user options @@ -115,15 +103,15 @@ func (s *session) FromTool(ctx context.Context, call string, result any, opts .. chatopts = append(chatopts, opts...) // Call the 'chat' method - r, err := s.model.client.Chat(ctx, response, chatopts...) + r, err := s.model.client.Chat(ctx, s, chatopts...) if err != nil { - return nil, err + return err } else { - response.seq = append(response.seq, &r.Message) + s.seq = append(s.seq, &r.Message) } // Return success - return response, nil + return nil } // Return the role of the last message @@ -142,6 +130,25 @@ func (session *session) Text() string { return session.seq[len(session.seq)-1].Content } +// Return the tool calls of the last message +func (session *session) ToolCalls() []llm.ToolCall { + // Sanity check for tool call + if len(session.seq) == 0 { + return nil + } + meta := session.seq[len(session.seq)-1] + if meta.Role != "assistant" { + return nil + } + + // Gather tool calls + var result []llm.ToolCall + for _, call := range meta.ToolCalls { + result = append(result, NewToolCall(call)) + } + return result +} + /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/ollama/session_test.go b/pkg/ollama/session_test.go index a22bbab..0e40abb 100644 --- a/pkg/ollama/session_test.go +++ b/pkg/ollama/session_test.go @@ -31,12 +31,12 @@ func Test_session_001(t *testing.T) { })) assert.NotNil(session) - new_session, err := session.FromUser(context.TODO(), "Why is the grass green?") + err := session.FromUser(context.TODO(), "Why is the grass green?") if !assert.NoError(err) { t.FailNow() } - assert.Equal("assistant", new_session.Role()) - assert.NotEmpty(new_session.Text()) + assert.Equal("assistant", session.Role()) + assert.NotEmpty(session.Text()) }) // Session with a single user prompt - not streaming @@ -45,12 +45,12 @@ func Test_session_001(t *testing.T) { session := model.Context() assert.NotNil(session) - new_session, err := session.FromUser(context.TODO(), "Why is the sky blue?") + err := session.FromUser(context.TODO(), "Why is the sky blue?") if !assert.NoError(err) { t.FailNow() } - assert.Equal("assistant", new_session.Role()) - assert.NotEmpty(new_session.Text()) + assert.Equal("assistant", session.Role()) + assert.NotEmpty(session.Text()) }) } @@ -77,10 +77,11 @@ func Test_session_002(t *testing.T) { session := model.Context(ollama.WithTool(tool)) assert.NotNil(session) - new_session, err := session.FromUser(context.TODO(), "What is today's weather?") + + err = session.FromUser(context.TODO(), "What is today's weather?") if !assert.NoError(err) { t.FailNow() } - t.Log(new_session) + t.Log(session) }) } diff --git a/pkg/ollama/tool.go b/pkg/ollama/tool.go index 4d85ba6..78704df 100644 --- a/pkg/ollama/tool.go +++ b/pkg/ollama/tool.go @@ -93,6 +93,11 @@ func NewTool(name, description string, params any) (*Tool, error) { return &tool, nil } +// Return a new tool call +func NewToolCall(v ToolCall) *ToolCallFunction { + return &v.Function +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY diff --git a/pkg/tool/tollcall.go b/pkg/tool/tollcall.go new file mode 100644 index 0000000..2339413 --- /dev/null +++ b/pkg/tool/tollcall.go @@ -0,0 +1,9 @@ +package tool + +// Packages + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type toolcall struct { +} diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go new file mode 100644 index 0000000..6661934 --- /dev/null +++ b/pkg/tool/tool.go @@ -0,0 +1,145 @@ +package tool + +import ( + "context" + "reflect" + "strings" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type ToolParameter struct { + Name string `json:"-"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + required bool + index []int // Field index into prototype for setting a field +} + +type tool struct { + ToolMeta + proto reflect.Type // Prototype for parameter return +} + +type ToolMeta struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters struct { + Type string `json:"type,omitempty"` + Required []string `json:"required,omitempty"` + Properties map[string]ToolParameter `json:"properties,omitempty"` + } `json:"input_schema"` +} + +var _ llm.Tool = (*tool)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (t tool) Name() string { + return t.ToolMeta.Name +} + +func (t tool) Description() string { + return t.ToolMeta.Description +} + +func (tool) Run(context.Context) (any, error) { + return nil, llm.ErrNotImplemented +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +// Return tool parameters from a struct +func paramsFor(params any) ([]ToolParameter, error) { + if params == nil { + return []ToolParameter{}, nil + } + rt := reflect.TypeOf(params) + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return nil, llm.ErrBadParameter.With("params must be a struct") + } + + // Iterate over fields + fields := reflect.VisibleFields(rt) + result := make([]ToolParameter, 0, len(fields)) + for _, field := range fields { + if param, err := paramFor(field); err != nil { + return nil, err + } else { + result = append(result, param) + } + } + + // Return success + return result, nil +} + +// Return tool parameters from a struct field +func paramFor(field reflect.StructField) (ToolParameter, error) { + // Name + name := field.Tag.Get("name") + if name == "" { + name = field.Name + } + + // Type + typ, err := paramType(field) + if err != nil { + return ToolParameter{}, err + } + + // Required + _, required := field.Tag.Lookup("required") + + // Enum + enum := []string{} + if enum_ := field.Tag.Get("enum"); enum_ != "" { + enum = strings.Split(enum_, ",") + } + + // Return success + return ToolParameter{ + Name: field.Name, + Type: typ, + Description: field.Tag.Get("help"), + Enum: enum, + required: required, + index: field.Index, + }, nil +} + +var ( + typeString = reflect.TypeOf("") + typeUint = reflect.TypeOf(uint(0)) + typeInt = reflect.TypeOf(int(0)) + typeFloat64 = reflect.TypeOf(float64(0)) + typeFloat32 = reflect.TypeOf(float32(0)) +) + +// Return parameter type from a struct field +func paramType(field reflect.StructField) (string, error) { + t := field.Type + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + switch field.Type { + case typeString: + return "string", nil + case typeUint, typeInt: + return "integer", nil + case typeFloat64, typeFloat32: + return "number", nil + default: + return "", llm.ErrBadParameter.Withf("unsupported type %v for field %q", field.Type, field.Name) + } +} diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go new file mode 100644 index 0000000..55a4d44 --- /dev/null +++ b/pkg/tool/toolkit.go @@ -0,0 +1,86 @@ +package tool + +import ( + "reflect" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +//////////////////////////////////////////////////////////////////////////////// +// TYPES + +// ToolKit represents a toolkit of tools +type ToolKit struct { + functions map[string]tool +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// Create a new empty toolkit +func NewToolKit() *ToolKit { + return &ToolKit{ + functions: make(map[string]tool), + } +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Return all registered tools +func (kit *ToolKit) Tools() []llm.Tool { + result := make([]llm.Tool, 0, len(kit.functions)) + for _, t := range kit.functions { + result = append(result, t) + } + return result +} + +// Register a tool in the toolkit +func (kit *ToolKit) Register(v llm.Tool) error { + if v == nil { + return llm.ErrBadParameter.With("tool cannot be nil") + } + + name := v.Name() + if _, exists := kit.functions[name]; exists { + return llm.ErrConflict.Withf("tool %q already exists", name) + } + + // Set the tool + t := tool{ + ToolMeta: ToolMeta{ + Name: name, + Description: v.Description(), + }, + proto: reflect.TypeOf(v), + } + + // Add parameters + t.Parameters.Type = "object" + toolparams, err := paramsFor(v) + if err != nil { + return err + } + + // Set parameters + t.Parameters.Required = make([]string, 0, len(toolparams)) + t.Parameters.Properties = make(map[string]ToolParameter, len(toolparams)) + for _, param := range toolparams { + if _, exists := t.Parameters.Properties[param.Name]; exists { + return llm.ErrConflict.Withf("parameter %q already exists", param.Name) + } else { + t.Parameters.Properties[param.Name] = param + } + if param.required { + t.Parameters.Required = append(t.Parameters.Required, param.Name) + } + } + + // Add to toolkit + kit.functions[name] = t + + // Return success + return nil +} diff --git a/response.go b/response.go deleted file mode 100644 index 956f783..0000000 --- a/response.go +++ /dev/null @@ -1,29 +0,0 @@ -package llm - -import ( - "encoding/json" - "time" -) - -////////////////////////////////////////////////////////////////// -// TYPES - -type Response struct { - Agent string `json:"agent,omitempty"` // The agent name - Model string `json:"model,omitempty"` // The model name - Context []Context `json:"context,omitempty"` // The context for the response - Text string `json:"text,omitempty"` // The response text - Tokens uint `json:"tokens,omitempty"` // The number of tokens - Duration time.Duration `json:"duration,omitempty"` // The response duration -} - -////////////////////////////////////////////////////////////////// -// STRINGIFY - -func (r Response) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() - } - return string(data) -} diff --git a/tool.go b/tool.go index a61b0d5..36b785a 100644 --- a/tool.go +++ b/tool.go @@ -7,17 +7,25 @@ import ( //////////////////////////////////////////////////////////////////////////////// // TYPES -// A tool can be called from an LLM type Tool interface { - // Return the name of the tool + // The name of the tool Name() string - // Return the description of the tool + // The description of the tool Description() string - // Tool parameters - Params() any + // Run the tool with a deadline and return the result + Run(context.Context) (any, error) +} + +// A call-out to a tool +type ToolCall interface { + // The tool name + Name() string + + // The tool identifier + Id() string - // Execute the tool with parameters - Run(context.Context, any) error + // The calling parameters + Params() any } From a05b1f810bf18dfe311aa3d5aad4984963ac28c6 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 18:24:29 +0100 Subject: [PATCH 23/33] Updated tool --- pkg/anthropic/message.go | 1 + pkg/anthropic/messages_test.go | 37 +++++++++++++------ pkg/anthropic/session.go | 3 +- pkg/anthropic/session_test.go | 9 ++--- pkg/tool/call.go | 66 ++++++++++++++++++++++++++++++++++ pkg/tool/tollcall.go | 9 ----- pkg/tool/tool.go | 2 +- pkg/tool/toolkit.go | 44 ++++++++++++++++++++++- tool.go | 5 +-- 9 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 pkg/tool/call.go delete mode 100644 pkg/tool/tollcall.go diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 083296a..191e12b 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -8,6 +8,7 @@ import ( "os" "strings" + // Packages llm "github.com/mutablelogic/go-llm" ) diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 90280be..ed5fd66 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -7,7 +7,9 @@ import ( // Packages opts "github.com/mutablelogic/go-client" + "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -107,14 +109,12 @@ func Test_messages_004(t *testing.T) { t.FailNow() } - weather, err := anthropic.NewTool("weather_in_location", "Get the weather in a location", struct { - Location string `name:"location" help:"The location to get the weather for" required:"true"` - }{}) - if !assert.NoError(err) { + toolkit := tool.NewToolKit() + if err := toolkit.Register(new(weather)); !assert.NoError(err) { t.FailNow() } - response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithTool(weather)) + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithToolKit(toolkit)) if assert.NoError(err) { t.Log(response) } @@ -136,17 +136,34 @@ func Test_messages_005(t *testing.T) { t.FailNow() } - weather, err := anthropic.NewTool("weather_in_location", "Get the weather in a location", struct { - Location string `name:"location" help:"The location to get the weather for" required:"true"` - }{}) - if !assert.NoError(err) { + toolkit := tool.NewToolKit() + if err := toolkit.Register(new(weather)); !assert.NoError(err) { t.FailNow() } response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { t.Log(r) - }), anthropic.WithTool(weather)) + }), anthropic.WithToolKit(toolkit)) if assert.NoError(err) { t.Log(response) } } + +//////////////////////////////////////////////////////////////////////////////// +// TOOLS + +type weather struct { + Location string `name:"location" help:"The location to get the weather for" required:"true"` +} + +func (*weather) Name() string { + return "weather_in_location" +} + +func (*weather) Description() string { + return "Get the weather in a location" +} + +func (*weather) Run(ctx context.Context) (any, error) { + return nil, llm.ErrNotImplemented +} diff --git a/pkg/anthropic/session.go b/pkg/anthropic/session.go index 32c20cb..c5519a6 100644 --- a/pkg/anthropic/session.go +++ b/pkg/anthropic/session.go @@ -6,6 +6,7 @@ import ( // Packages llm "github.com/mutablelogic/go-llm" + tool "github.com/mutablelogic/go-llm/pkg/tool" ) ////////////////////////////////////////////////////////////////// @@ -103,7 +104,7 @@ func (session *session) ToolCalls() []llm.ToolCall { var result []llm.ToolCall for _, content := range meta.Content { if content.Type == "tool_use" { - result = append(result, NewToolCall(content)) + result = append(result, tool.NewCall(content.ContentTool.Id, content.ContentTool.Name, content.ContentTool.Input)) } } return result diff --git a/pkg/anthropic/session_test.go b/pkg/anthropic/session_test.go index 2dff9ac..7554671 100644 --- a/pkg/anthropic/session_test.go +++ b/pkg/anthropic/session_test.go @@ -8,6 +8,7 @@ import ( // Packages opts "github.com/mutablelogic/go-client" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" + "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -68,12 +69,12 @@ func Test_session_002(t *testing.T) { t.Run("toolcall", func(t *testing.T) { assert := assert.New(t) - tool, err := anthropic.NewTool("get_weather", "Return the current weather", nil) - if !assert.NoError(err) { + toolkit := tool.NewToolKit() + if err := toolkit.Register(new(weather)); !assert.NoError(err) { t.FailNow() } - session := model.Context(anthropic.WithTool(tool)) + session := model.Context(anthropic.WithToolKit(toolkit)) assert.NotNil(session) err = session.FromUser(context.TODO(), "What is today's weather?") @@ -83,6 +84,6 @@ func Test_session_002(t *testing.T) { toolcalls := session.ToolCalls() assert.NotEmpty(toolcalls) - t.Log(toolcalls) + t.Log("TOOLCALLS", toolcalls) }) } diff --git a/pkg/tool/call.go b/pkg/tool/call.go new file mode 100644 index 0000000..301d2a1 --- /dev/null +++ b/pkg/tool/call.go @@ -0,0 +1,66 @@ +package tool + +import ( + // Packages + "bytes" + "encoding/json" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type CallMeta struct { + Name string `json:"name"` + Id string `json:"id,omitempty"` + Input map[string]any `json:"input,omitempty"` +} + +type call struct { + meta CallMeta +} + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func NewCall(name, id string, input map[string]any) *call { + return &call{ + meta: CallMeta{ + Name: name, + Id: id, + Input: input, + }, + } +} + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (t *call) String() string { + data, err := json.MarshalIndent(t.meta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (t *call) Name() string { + return t.meta.Name +} + +func (t *call) Id() string { + return t.meta.Id +} + +func (t *call) Decode(v any) error { + var buf bytes.Buffer + if data, err := json.Marshal(t.meta.Input); err != nil { + return err + } else if err := json.Unmarshal(data, &buf); err != nil { + return err + } + // Return success + return nil +} diff --git a/pkg/tool/tollcall.go b/pkg/tool/tollcall.go deleted file mode 100644 index 2339413..0000000 --- a/pkg/tool/tollcall.go +++ /dev/null @@ -1,9 +0,0 @@ -package tool - -// Packages - -/////////////////////////////////////////////////////////////////////////////// -// TYPES - -type toolcall struct { -} diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index 6661934..193307a 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -23,7 +23,7 @@ type ToolParameter struct { type tool struct { ToolMeta - proto reflect.Type // Prototype for parameter return + proto reflect.Type } type ToolMeta struct { diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go index 55a4d44..86619f6 100644 --- a/pkg/tool/toolkit.go +++ b/pkg/tool/toolkit.go @@ -1,9 +1,13 @@ package tool import ( + // Packages + "context" + "errors" + "fmt" "reflect" + "sync" - // Packages llm "github.com/mutablelogic/go-llm" ) @@ -84,3 +88,41 @@ func (kit *ToolKit) Register(v llm.Tool) error { // Return success return nil } + +// Run calls a tool in the toolkit +func (kit *ToolKit) Run(ctx context.Context, calls []llm.ToolCall) error { + var wg sync.WaitGroup + var result error + + for _, call := range calls { + wg.Add(1) + go func(call llm.ToolCall) { + defer wg.Done() + + // Get the tool + name := call.Name() + t, exists := kit.functions[name] + if !exists { + result = errors.Join(result, llm.ErrNotFound.Withf("tool %q not found", name)) + } + + // Make a new object to decode into + v := reflect.New(t.proto).Interface() + + // Decode the input and run the tool + if err := call.Decode(&v); err != nil { + result = errors.Join(result, err) + } else if out, err := t.Run(ctx, v); err != nil { + result = errors.Join(result, err) + } else { + fmt.Println("result of calling", call, "is", out) + } + }(call) + } + + // Wait for all calls to complete + wg.Wait() + + // Return any errors + return result +} diff --git a/tool.go b/tool.go index 36b785a..77c1de0 100644 --- a/tool.go +++ b/tool.go @@ -7,6 +7,7 @@ import ( //////////////////////////////////////////////////////////////////////////////// // TYPES +// Definition of a tool type Tool interface { // The name of the tool Name() string @@ -26,6 +27,6 @@ type ToolCall interface { // The tool identifier Id() string - // The calling parameters - Params() any + // Decode the calling parameters + Decode(v any) error } From a709077f56a5d526788085285e0254861b883058 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 19:12:50 +0100 Subject: [PATCH 24/33] More tool calling work --- pkg/anthropic/messages.go | 2 +- pkg/anthropic/messages_test.go | 18 ++++-- pkg/anthropic/opt.go | 6 +- pkg/anthropic/session_test.go | 9 +-- pkg/ollama/chat.go | 4 +- pkg/ollama/chat_test.go | 41 ++++++++++++- pkg/ollama/message.go | 18 ++++++ pkg/ollama/opt.go | 30 ++++++---- pkg/ollama/session.go | 4 +- pkg/ollama/session_test.go | 14 +++-- pkg/tool/call.go | 11 ++-- pkg/tool/tool.go | 42 ++++++------- pkg/{ollama/tool.go => tool/tool.go_old} | 12 ---- .../tool_test.go => tool/tool_test.go_old} | 0 pkg/tool/toolkit.go | 59 +++++++++++-------- 15 files changed, 171 insertions(+), 99 deletions(-) rename pkg/{ollama/tool.go => tool/tool.go_old} (94%) rename pkg/{ollama/tool_test.go => tool/tool_test.go_old} (100%) diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 10326e2..ef92b60 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -78,7 +78,7 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts req, err := client.NewJSONRequest(reqMessages{ Model: context.(*session).model.Name(), Messages: context.(*session).seq, - Tools: opt.Tools(), + Tools: opt.tools(anthropic), opt: *opt, }) if err != nil { diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index ed5fd66..270412f 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -2,12 +2,13 @@ package anthropic_test import ( "context" + "encoding/json" + "log" "os" "testing" // Packages opts "github.com/mutablelogic/go-client" - "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" @@ -153,7 +154,7 @@ func Test_messages_005(t *testing.T) { // TOOLS type weather struct { - Location string `name:"location" help:"The location to get the weather for" required:"true"` + Location string `json:"location" name:"location" help:"The location to get the weather for" required:"true"` } func (*weather) Name() string { @@ -164,6 +165,15 @@ func (*weather) Description() string { return "Get the weather in a location" } -func (*weather) Run(ctx context.Context) (any, error) { - return nil, llm.ErrNotImplemented +func (weather *weather) String() string { + data, err := json.MarshalIndent(weather, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +func (weather *weather) Run(ctx context.Context) (any, error) { + log.Println("weather_in_location", "=>", weather) + return "very sunny today", nil } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 3e72a61..bcbbb78 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -44,13 +44,13 @@ func apply(opts ...llm.Opt) (*opt, error) { } //////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS +// PRIVATE METHODS -func (o *opt) Tools() []llm.Tool { +func (o *opt) tools(agent llm.Agent) []llm.Tool { if o.toolkit == nil { return nil } else { - return o.toolkit.Tools() + return o.toolkit.Tools(agent) } } diff --git a/pkg/anthropic/session_test.go b/pkg/anthropic/session_test.go index 7554671..fe0c094 100644 --- a/pkg/anthropic/session_test.go +++ b/pkg/anthropic/session_test.go @@ -77,13 +77,14 @@ func Test_session_002(t *testing.T) { session := model.Context(anthropic.WithToolKit(toolkit)) assert.NotNil(session) - err = session.FromUser(context.TODO(), "What is today's weather?") + err = session.FromUser(context.TODO(), "What is today's weather, in Berlin?") if !assert.NoError(err) { t.FailNow() } - toolcalls := session.ToolCalls() - assert.NotEmpty(toolcalls) - t.Log("TOOLCALLS", toolcalls) + err := toolkit.Run(context.TODO(), session.ToolCalls()) + if !assert.NoError(err) { + t.FailNow() + } }) } diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index ccd01b4..e55be83 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -50,7 +50,7 @@ func (r Response) String() string { type reqChat struct { Model string `json:"model"` Messages []*MessageMeta `json:"messages"` - Tools []*Tool `json:"tools,omitempty"` + Tools []ToolFunction `json:"tools,omitempty"` Format string `json:"format,omitempty"` Options map[string]interface{} `json:"options,omitempty"` Stream bool `json:"stream"` @@ -68,7 +68,7 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. req, err := client.NewJSONRequest(reqChat{ Model: prompt.(*session).model.Name(), Messages: prompt.(*session).seq, - Tools: opt.tools, + Tools: opt.tools(ollama), Format: opt.format, Options: opt.options, Stream: opt.stream, diff --git a/pkg/ollama/chat_test.go b/pkg/ollama/chat_test.go index ddbd5b7..0004818 100644 --- a/pkg/ollama/chat_test.go +++ b/pkg/ollama/chat_test.go @@ -2,12 +2,15 @@ package ollama_test import ( "context" + "encoding/json" + "log" "os" "testing" // Packages opts "github.com/mutablelogic/go-client" ollama "github.com/mutablelogic/go-llm/pkg/ollama" + "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -60,13 +63,17 @@ func Test_chat_002(t *testing.T) { t.FailNow() } + // Make a toolkit + toolkit := tool.NewToolKit() + if err := toolkit.Register(new(weather)); err != nil { + t.FailNow() + } + t.Run("Tools", func(t *testing.T) { assert := assert.New(t) response, err := client.Chat(context.TODO(), model.UserPrompt("what is the weather in berlin?"), - ollama.WithTool(ollama.MustTool("get_weather", "Return weather conditions in a location", struct { - Location string `help:"Location to get weather for" required:""` - }{})), + ollama.WithToolKit(toolkit), ) if !assert.NoError(err) { t.FailNow() @@ -108,3 +115,31 @@ func Test_chat_003(t *testing.T) { t.Log(response) }) } + +//////////////////////////////////////////////////////////////////////////////// +// TOOLS + +type weather struct { + Location string `json:"location" name:"location" help:"The location to get the weather for" required:"true"` +} + +func (*weather) Name() string { + return "weather_in_location" +} + +func (*weather) Description() string { + return "Get the weather in a location" +} + +func (weather *weather) String() string { + data, err := json.MarshalIndent(weather, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +func (weather *weather) Run(ctx context.Context) (any, error) { + log.Println("weather_in_location", "=>", weather) + return "very sunny today", nil +} diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index 61c9b42..ee9e81a 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -1,5 +1,7 @@ package ollama +import llm "github.com/mutablelogic/go-llm" + /////////////////////////////////////////////////////////////////////////////// // TYPES @@ -12,5 +14,21 @@ type MessageMeta struct { ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Tool calls from the assistant } +type ToolCall struct { + Function ToolCallFunction `json:"function"` +} + +type ToolCallFunction struct { + Index int `json:"index,omitempty"` + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` +} + // Data represents the raw binary data of an image file. type Data []byte + +// ToolFunction +type ToolFunction struct { + Type string `json:"type"` // function + Function llm.Tool `json:"function"` +} diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go index f4a7541..9af64c5 100644 --- a/pkg/ollama/opt.go +++ b/pkg/ollama/opt.go @@ -6,6 +6,7 @@ import ( // Packages llm "github.com/mutablelogic/go-llm" + "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// @@ -20,8 +21,8 @@ type opt struct { truncate *bool keepalive *time.Duration options map[string]any - tools []*Tool data []Data + toolkit *tool.ToolKit // Toolkit for tools } //////////////////////////////////////////////////////////////////////////////// @@ -38,6 +39,20 @@ func apply(opts ...llm.Opt) (*opt, error) { return o, nil } +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func (o *opt) tools(agent llm.Agent) []ToolFunction { + if o.toolkit == nil { + return nil + } + var result []ToolFunction + for _, t := range o.toolkit.Tools(agent) { + result = append(result, ToolFunction{Type: "function", Function: t}) + } + return result +} + //////////////////////////////////////////////////////////////////////////////// // OPTIONS @@ -85,24 +100,17 @@ func WithStream(fn func(*Response)) llm.Opt { if fn == nil { return llm.ErrBadParameter.With("callback required") } - if len(o.(*opt).tools) > 0 { - return llm.ErrBadParameter.With("streaming not supported with tools") - } o.(*opt).stream = true o.(*opt).chatcallback = fn return nil } } -// Chat: Append a tool to the request. -func WithTool(v *Tool) llm.Opt { +// Chat: Append a toolkit to the request +func WithToolKit(v *tool.ToolKit) llm.Opt { return func(o any) error { - // We can't use streaming when tools are included - if o.(*opt).stream { - return llm.ErrBadParameter.With("tools not supported with streaming") - } if v != nil { - o.(*opt).tools = append(o.(*opt).tools, v) + o.(*opt).toolkit = v } return nil } diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 234538d..6c1987e 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -3,9 +3,11 @@ package ollama import ( "context" "encoding/json" + "fmt" // Packages llm "github.com/mutablelogic/go-llm" + "github.com/mutablelogic/go-llm/pkg/tool" ) /////////////////////////////////////////////////////////////////////////////// @@ -144,7 +146,7 @@ func (session *session) ToolCalls() []llm.ToolCall { // Gather tool calls var result []llm.ToolCall for _, call := range meta.ToolCalls { - result = append(result, NewToolCall(call)) + result = append(result, tool.NewCall(fmt.Sprint(call.Function.Index), call.Function.Name, call.Function.Arguments)) } return result } diff --git a/pkg/ollama/session_test.go b/pkg/ollama/session_test.go index 0e40abb..ac4a916 100644 --- a/pkg/ollama/session_test.go +++ b/pkg/ollama/session_test.go @@ -8,6 +8,7 @@ import ( // Packages opts "github.com/mutablelogic/go-client" ollama "github.com/mutablelogic/go-llm/pkg/ollama" + "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -66,16 +67,17 @@ func Test_session_002(t *testing.T) { t.FailNow() } + // Make a toolkit + toolkit := tool.NewToolKit() + if err := toolkit.Register(new(weather)); err != nil { + t.FailNow() + } + // Session with a tool call t.Run("toolcall", func(t *testing.T) { assert := assert.New(t) - tool, err := ollama.NewTool("get_weather", "Return the current weather", nil) - if !assert.NoError(err) { - t.FailNow() - } - - session := model.Context(ollama.WithTool(tool)) + session := model.Context(ollama.WithToolKit(toolkit)) assert.NotNil(session) err = session.FromUser(context.TODO(), "What is today's weather?") diff --git a/pkg/tool/call.go b/pkg/tool/call.go index 301d2a1..74041f7 100644 --- a/pkg/tool/call.go +++ b/pkg/tool/call.go @@ -2,7 +2,7 @@ package tool import ( // Packages - "bytes" + "encoding/json" ) @@ -22,7 +22,7 @@ type call struct { /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -func NewCall(name, id string, input map[string]any) *call { +func NewCall(id, name string, input map[string]any) *call { return &call{ meta: CallMeta{ Name: name, @@ -55,12 +55,9 @@ func (t *call) Id() string { } func (t *call) Decode(v any) error { - var buf bytes.Buffer if data, err := json.Marshal(t.meta.Input); err != nil { return err - } else if err := json.Unmarshal(data, &buf); err != nil { - return err + } else { + return json.Unmarshal(data, v) } - // Return success - return nil } diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index 193307a..733bf58 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -1,7 +1,6 @@ package tool import ( - "context" "reflect" "strings" @@ -12,31 +11,36 @@ import ( /////////////////////////////////////////////////////////////////////////////// // TYPES -type ToolParameter struct { - Name string `json:"-"` - Type string `json:"type"` - Description string `json:"description,omitempty"` - Enum []string `json:"enum,omitempty"` - required bool - index []int // Field index into prototype for setting a field -} - type tool struct { + llm.Tool `json:"-"` ToolMeta - proto reflect.Type } +var _ llm.Tool = (*tool)(nil) + type ToolMeta struct { Name string `json:"name"` Description string `json:"description"` - Parameters struct { - Type string `json:"type,omitempty"` - Required []string `json:"required,omitempty"` - Properties map[string]ToolParameter `json:"properties,omitempty"` - } `json:"input_schema"` + + // Variation on how schema is output + Parameters *ToolParameters `json:"parameters,omitempty"` + InputSchema *ToolParameters `json:"input_schema,omitempty"` } -var _ llm.Tool = (*tool)(nil) +type ToolParameters struct { + Type string `json:"type,omitempty"` + Required []string `json:"required,omitempty"` + Properties map[string]ToolParameter `json:"properties,omitempty"` +} + +type ToolParameter struct { + Name string `json:"-"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + required bool + index []int // Field index into prototype for setting a field +} /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -49,10 +53,6 @@ func (t tool) Description() string { return t.ToolMeta.Description } -func (tool) Run(context.Context) (any, error) { - return nil, llm.ErrNotImplemented -} - /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/ollama/tool.go b/pkg/tool/tool.go_old similarity index 94% rename from pkg/ollama/tool.go rename to pkg/tool/tool.go_old index 78704df..2da2bee 100644 --- a/pkg/ollama/tool.go +++ b/pkg/tool/tool.go_old @@ -39,18 +39,6 @@ type ToolParameter struct { index []int // Field index into prototype for setting a field } -type ToolCall struct { - Function ToolCallFunction `json:"function"` -} - -type ToolCallFunction struct { - Index int `json:"index,omitempty"` - Name string `json:"name"` - Arguments ToolCallFunctionArguments `json:"arguments"` -} - -type ToolCallFunctionArguments map[string]any - /////////////////////////////////////////////////////////////////////////////// // LIFECYCLE diff --git a/pkg/ollama/tool_test.go b/pkg/tool/tool_test.go_old similarity index 100% rename from pkg/ollama/tool_test.go rename to pkg/tool/tool_test.go_old diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go index 86619f6..e4247c1 100644 --- a/pkg/tool/toolkit.go +++ b/pkg/tool/toolkit.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "reflect" "sync" llm "github.com/mutablelogic/go-llm" @@ -22,7 +21,7 @@ type ToolKit struct { //////////////////////////////////////////////////////////////////////////////// // LIFECYCLE -// Create a new empty toolkit +// Create a new empty toolkit for an agent func NewToolKit() *ToolKit { return &ToolKit{ functions: make(map[string]tool), @@ -32,11 +31,18 @@ func NewToolKit() *ToolKit { //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -// Return all registered tools -func (kit *ToolKit) Tools() []llm.Tool { +// Return all registered tools for a specific agent +func (kit *ToolKit) Tools(agent llm.Agent) []llm.Tool { result := make([]llm.Tool, 0, len(kit.functions)) for _, t := range kit.functions { - result = append(result, t) + switch agent.Name() { + case "ollama": + t.InputSchema = nil + result = append(result, t) + default: + t.Parameters = nil + result = append(result, t) + } } return result } @@ -54,34 +60,41 @@ func (kit *ToolKit) Register(v llm.Tool) error { // Set the tool t := tool{ + Tool: v, ToolMeta: ToolMeta{ Name: name, Description: v.Description(), }, - proto: reflect.TypeOf(v), } - // Add parameters - t.Parameters.Type = "object" + // Determine parameters toolparams, err := paramsFor(v) if err != nil { return err } + // Add parameters + parameters := ToolParameters{ + Type: "object", + Required: make([]string, 0, len(toolparams)), + Properties: make(map[string]ToolParameter, len(toolparams)), + } + // Set parameters - t.Parameters.Required = make([]string, 0, len(toolparams)) - t.Parameters.Properties = make(map[string]ToolParameter, len(toolparams)) for _, param := range toolparams { - if _, exists := t.Parameters.Properties[param.Name]; exists { + if _, exists := parameters.Properties[param.Name]; exists { return llm.ErrConflict.Withf("parameter %q already exists", param.Name) } else { - t.Parameters.Properties[param.Name] = param + parameters.Properties[param.Name] = param } if param.required { - t.Parameters.Required = append(t.Parameters.Required, param.Name) + parameters.Required = append(parameters.Required, param.Name) } } + t.Parameters = ¶meters + t.InputSchema = ¶meters + // Add to toolkit kit.functions[name] = t @@ -94,27 +107,25 @@ func (kit *ToolKit) Run(ctx context.Context, calls []llm.ToolCall) error { var wg sync.WaitGroup var result error + // TODO: Lock each tool so it can only be run in series (although different + // tools can be run in parallel) for _, call := range calls { wg.Add(1) go func(call llm.ToolCall) { defer wg.Done() - // Get the tool + fmt.Println(call) + + // Get the tool and run it name := call.Name() - t, exists := kit.functions[name] - if !exists { + if _, exists := kit.functions[name]; !exists { result = errors.Join(result, llm.ErrNotFound.Withf("tool %q not found", name)) - } - - // Make a new object to decode into - v := reflect.New(t.proto).Interface() - - // Decode the input and run the tool - if err := call.Decode(&v); err != nil { + } else if err := call.Decode(kit.functions[name].Tool); err != nil { result = errors.Join(result, err) - } else if out, err := t.Run(ctx, v); err != nil { + } else if out, err := kit.functions[name].Run(ctx); err != nil { result = errors.Join(result, err) } else { + // TODO: Return the result alongside the call fmt.Println("result of calling", call, "is", out) } }(call) From c8386a52590973c38b30ddcbb6190ec63a241ee6 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 19:30:58 +0100 Subject: [PATCH 25/33] Updated --- cmd/agent/generate.go | 11 ++--------- cmd/agent/models.go | 12 +++++++++++- pkg/agent/agent.go | 20 ++++++++++---------- pkg/agent/context.go | 6 ------ pkg/agent/generate.go | 38 -------------------------------------- pkg/agent/opt.go | 20 ++++++++------------ 6 files changed, 31 insertions(+), 76 deletions(-) delete mode 100644 pkg/agent/context.go delete mode 100644 pkg/agent/generate.go diff --git a/cmd/agent/generate.go b/cmd/agent/generate.go index 962bb4a..792a8e8 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/generate.go @@ -37,9 +37,6 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { // Create a session session := model.Context(agent.WithStream(!cmd.NoStream)) - if err != nil { - return err - } // Continue looping until end of input for { @@ -57,14 +54,10 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { } // Feed input into the model - response, err := session.FromUser(ctx, input) - if err != nil { + if err := session.FromUser(ctx, input); err != nil { return err } - fmt.Println(response.Text()) - - // Update session - session = response + fmt.Println(session.Text()) } }) } diff --git a/cmd/agent/models.go b/cmd/agent/models.go index 4a04bd7..ad136ec 100644 --- a/cmd/agent/models.go +++ b/cmd/agent/models.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" // Packages @@ -48,9 +49,18 @@ func (*ListAgentsCmd) Run(globals *Globals) error { if !ok { return fmt.Errorf("No agents found") } + + var agents []string for _, agent := range agent.Agents() { - fmt.Println(agent) + agents = append(agents, agent.Name()) + } + + data, err := json.MarshalIndent(agents, "", " ") + if err != nil { + return err } + fmt.Println(string(data)) + return nil }) } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 2876f9e..2c10a77 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -66,11 +66,14 @@ func (a *Agent) Agents() []llm.Agent { // Return a list of tool names func (a *Agent) Tools() []string { - var keys []string - for k := range a.tools { - keys = append(keys, k) + if a.toolkit == nil { + return nil + } + var result []string + for _, t := range a.toolkit.Tools(a) { + result = append(result, t.Name()) } - return keys + return result } // Return a comma-separated list of agent names @@ -121,7 +124,9 @@ func (a *Agent) ListModels(ctx context.Context, agents ...string) ([]llm.Model, // If multiple agents are specified, then the first model found is returned. func (a *Agent) GetModel(ctx context.Context, name string, agents ...string) (llm.Model, error) { if len(agents) == 0 { - agents = a.Agents() + for _, agent := range a.agents { + agents = append(agents, agent.Name()) + } } // Ensure all agents are valid @@ -155,11 +160,6 @@ func (a *Agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]flo return nil, llm.ErrNotImplemented } -// Create the result of calling a tool -func (a *Agent) ToolResult(id string, opts ...llm.Opt) llm.Context { - return nil -} - /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/agent/context.go b/pkg/agent/context.go deleted file mode 100644 index d8d60f4..0000000 --- a/pkg/agent/context.go +++ /dev/null @@ -1,6 +0,0 @@ -package agent - -// Packages - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS diff --git a/pkg/agent/generate.go b/pkg/agent/generate.go deleted file mode 100644 index 5148962..0000000 --- a/pkg/agent/generate.go +++ /dev/null @@ -1,38 +0,0 @@ -package agent - -import ( - "context" - "log" - - // Packages - llm "github.com/mutablelogic/go-llm" -) - -/////////////////////////////////////////////////////////////////////////////// -// PUBLIC METHODS - -// Generate a response from a prompt -func (a *Agent) Generate(ctx context.Context, m llm.Model, context llm.Context, opts ...llm.Opt) (llm.Context, error) { - // Obtain the agent - var agent llm.Agent - if model, ok := m.(*model); !ok || model == nil { - return nil, llm.ErrBadParameter.With("model") - } else if agent_, exists := a.agents[model.Agent]; !exists { - return nil, llm.ErrNotFound.Withf("agent %q", model.Agent) - } else { - agent = agent_ - } - - // Get the options - - // Apply the options - //opts, err := translate(agent, opts...) - //if err != nil { - // return nil, err - //} - - log.Print("agent.Generate =>", context) - - // Call Generate for the agent - return agent.Generate(ctx, m, context, opts...) -} diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index 241285b..2b870f7 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -8,14 +8,15 @@ import ( llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" ollama "github.com/mutablelogic/go-llm/pkg/ollama" + "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// // TYPES type opt struct { - agents map[string]llm.Agent - tools map[string]llm.Tool + agents map[string]llm.Agent + toolkit *tool.ToolKit // Translated options for each agent implementation ollama []llm.Opt @@ -62,17 +63,12 @@ func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { } } -// Append tools -func WithTools(tools ...llm.Tool) llm.Opt { +// Append toolkit +func WithToolKit(toolkit *tool.ToolKit) llm.Opt { return func(o any) error { - for _, tool := range tools { - name := tool.Name() - if _, exists := o.(*opt).tools[name]; exists { - return llm.ErrConflict.Withf("Tool %q already exists", name) - } - o.(*opt).tools[name] = tool - } - // Return success + o.(*opt).toolkit = toolkit + o.(*opt).ollama = append(o.(*opt).ollama, ollama.WithToolKit(toolkit)) + o.(*opt).anthropic = append(o.(*opt).anthropic, anthropic.WithToolKit(toolkit)) return nil } } From 938e7c2be7447855ba7632e5872c9059a7e87c59 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Fri, 31 Jan 2025 19:50:08 +0100 Subject: [PATCH 26/33] Updated to include NewsAPI --- cmd/agent/main.go | 23 +++++ pkg/newsapi/README.md | 8 ++ pkg/newsapi/agent.go | 188 +++++++++++++++++++++++++++++++++++ pkg/newsapi/articles.go | 82 +++++++++++++++ pkg/newsapi/articles_test.go | 38 +++++++ pkg/newsapi/client.go | 51 ++++++++++ pkg/newsapi/client_test.go | 31 ++++++ pkg/newsapi/opts.go | 114 +++++++++++++++++++++ pkg/newsapi/sources.go | 63 ++++++++++++ pkg/newsapi/sources_test.go | 25 +++++ pkg/tool/tool.go | 9 ++ 11 files changed, 632 insertions(+) create mode 100644 pkg/newsapi/README.md create mode 100644 pkg/newsapi/agent.go create mode 100644 pkg/newsapi/articles.go create mode 100644 pkg/newsapi/articles_test.go create mode 100644 pkg/newsapi/client.go create mode 100644 pkg/newsapi/client_test.go create mode 100644 pkg/newsapi/opts.go create mode 100644 pkg/newsapi/sources.go create mode 100644 pkg/newsapi/sources_test.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 9e9b727..382a47c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -12,6 +12,8 @@ import ( client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" agent "github.com/mutablelogic/go-llm/pkg/agent" + "github.com/mutablelogic/go-llm/pkg/newsapi" + "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// @@ -26,6 +28,9 @@ type Globals struct { Ollama `embed:"" help:"Ollama configuration"` Anthropic `embed:"" help:"Anthropic configuration"` + // Tools + NewsAPI `embed:"" help:"NewsAPI configuration"` + // Context ctx context.Context agent llm.Agent @@ -40,6 +45,10 @@ type Anthropic struct { AnthropicKey string `env:"ANTHROPIC_API_KEY" help:"Anthropic API Key"` } +type NewsAPI struct { + NewsKey string `env:"NEWSAPI_KEY" help:"News API Key"` +} + type CLI struct { Globals @@ -93,6 +102,20 @@ func main() { opts = append(opts, agent.WithAnthropic(cli.AnthropicKey, clientopts...)) } + // Make a toolkit + toolkit := tool.NewToolKit() + opts = append(opts, agent.WithToolKit(toolkit)) + + // NewsAPI + if cli.NewsKey != "" { + if client, err := newsapi.New(cli.NewsKey, clientopts...); err != nil { + cmd.FatalIfErrorf(err) + } else if err := client.RegisterWithToolKit(toolkit); err != nil { + cmd.FatalIfErrorf(err) + } + } + + // Create the agent agent, err := agent.New(opts...) cmd.FatalIfErrorf(err) cli.Globals.agent = agent diff --git a/pkg/newsapi/README.md b/pkg/newsapi/README.md new file mode 100644 index 0000000..ac53b94 --- /dev/null +++ b/pkg/newsapi/README.md @@ -0,0 +1,8 @@ +# NewsAPI Client + +This package provides a client for the NewsAPI API, which is used to interact with the NewsAPI service. + +References: + +- API https://newsapi.org/docs +- Package https://pkg.go.dev/github.com/mutablelogic/go-llm/pkg/newsapi diff --git a/pkg/newsapi/agent.go b/pkg/newsapi/agent.go new file mode 100644 index 0000000..abc3cfb --- /dev/null +++ b/pkg/newsapi/agent.go @@ -0,0 +1,188 @@ +package newsapi + +import ( + "context" + + // Packages + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type headlines struct { + *Client `json:"-"` +} + +var _ llm.Tool = (*headlines)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// HEADLINES + +func (headlines) Name() string { + return "current_headlines" +} + +func (headlines) Description() string { + return "Return the current news headlines" +} + +func (headlines *headlines) Run(ctx context.Context) (any, error) { + response, err := headlines.Headlines(OptCategory("general"), OptLimit(5)) + if err != nil { + return nil, err + } + return map[string]any{ + "type": "text", + "headlines": response, + }, nil +} + +/* +// Return all the agent tools for the weatherapi +func (c *Client) Tools() []agent.Tool { + return []agent.Tool{ + &tool{ + name: "current_headlines", + description: "Return the current news headlines", + run: c.agentCurrentHeadlines, + }, &tool{ + name: "current_headlines_country", + description: "Return the current news headlines for a country", + run: c.agentCountryHeadlines, + params: []agent.ToolParameter{ + { + Name: "countrycode", + Description: "The two-letter country code to return headlines for", + Required: true, + }, + }, + }, &tool{ + name: "current_headlines_category", + description: "Return the current news headlines for a business, entertainment, health, science, sports or technology", + run: c.agentCategoryHeadlines, + params: []agent.ToolParameter{ + { + Name: "category", + Description: "business, entertainment, health, science, sports, technology", + Required: true, + }, + }, + }, &tool{ + name: "search_news", + description: "Return the news headlines with a search query", + run: c.agentSearchNews, + params: []agent.ToolParameter{ + { + Name: "query", + Description: "A phrase used to search for news headlines", + Required: true, + }, + }, + }, + } +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS - TOOL + +func (*tool) Provider() string { + return "newsapi" +} + +func (t *tool) Name() string { + return t.name +} + +func (t *tool) Description() string { + return t.description +} + +func (t *tool) Params() []agent.ToolParameter { + return t.params +} + +func (t *tool) Run(ctx context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { + return t.run(ctx, call) +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS - TOOL + +// Return the current general headlines +func (c *Client) agentCurrentHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { + response, err := c.Headlines(OptCategory("general"), OptLimit(5)) + if err != nil { + return nil, err + } + return &agent.ToolResult{ + Id: call.Id, + Result: map[string]any{ + "type": "text", + "headlines": response, + }, + }, nil +} + +// Return the headlines for a specific country +func (c *Client) agentCountryHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { + country, err := call.String("countrycode") + if err != nil { + return nil, err + } + country = strings.ToLower(country) + response, err := c.Headlines(OptCountry(country), OptLimit(5)) + if err != nil { + return nil, err + } + return &agent.ToolResult{ + Id: call.Id, + Result: map[string]any{ + "type": "text", + "country": country, + "headlines": response, + }, + }, nil +} + +// Return the headlines for a specific category +func (c *Client) agentCategoryHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { + category, err := call.String("category") + if err != nil { + return nil, err + } + category = strings.ToLower(category) + response, err := c.Headlines(OptCategory(category), OptLimit(5)) + if err != nil { + return nil, err + } + return &agent.ToolResult{ + Id: call.Id, + Result: map[string]any{ + "type": "text", + "category": category, + "headlines": response, + }, + }, nil +} + +// Return the headlines for a specific query +func (c *Client) agentSearchNews(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { + query, err := call.String("query") + if err != nil { + return nil, err + } + response, err := c.Articles(OptQuery(query), OptLimit(5)) + if err != nil { + return nil, err + } + return &agent.ToolResult{ + Id: call.Id, + Result: map[string]any{ + "type": "text", + "query": query, + "headlines": response, + }, + }, nil +} +*/ diff --git a/pkg/newsapi/articles.go b/pkg/newsapi/articles.go new file mode 100644 index 0000000..f16b2f3 --- /dev/null +++ b/pkg/newsapi/articles.go @@ -0,0 +1,82 @@ +package newsapi + +import ( + "time" + + // Packages + "github.com/mutablelogic/go-client" + + // Namespace imports + . "github.com/djthorpe/go-errors" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Article struct { + Source Source `json:"source"` + Title string `json:"title"` + Author string `json:"author,omitempty"` + Description string `json:"description,omitempty"` + Url string `json:"url,omitempty"` + ImageUrl string `json:"urlToImage,omitempty"` + PublishedAt time.Time `json:"publishedAt,omitempty"` + Content string `json:"content,omitempty"` +} + +type respArticles struct { + Status string `json:"status"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + TotalResults int `json:"totalResults"` + Articles []Article `json:"articles"` +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Returns headlines +func (c *Client) Headlines(opt ...Opt) ([]Article, error) { + var response respArticles + var query opts + + // Add options + for _, opt := range opt { + if err := opt(&query); err != nil { + return nil, err + } + } + + // Request -> Response + if err := c.Do(nil, &response, client.OptPath("top-headlines"), client.OptQuery(query.Values())); err != nil { + return nil, err + } else if response.Status != "ok" { + return nil, ErrBadParameter.Withf("%s: %s", response.Code, response.Message) + } + + // Return success + return response.Articles, nil +} + +// Returns articles +func (c *Client) Articles(opt ...Opt) ([]Article, error) { + var response respArticles + var query opts + + // Add options + for _, opt := range opt { + if err := opt(&query); err != nil { + return nil, err + } + } + + // Request -> Response + if err := c.Do(nil, &response, client.OptPath("everything"), client.OptQuery(query.Values())); err != nil { + return nil, err + } else if response.Status != "ok" { + return nil, ErrBadParameter.Withf("%s: %s", response.Code, response.Message) + } + + // Return success + return response.Articles, nil +} diff --git a/pkg/newsapi/articles_test.go b/pkg/newsapi/articles_test.go new file mode 100644 index 0000000..8c44bd5 --- /dev/null +++ b/pkg/newsapi/articles_test.go @@ -0,0 +1,38 @@ +package newsapi_test + +import ( + "encoding/json" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + newsapi "github.com/mutablelogic/go-llm/pkg/newsapi" + assert "github.com/stretchr/testify/assert" +) + +func Test_articles_001(t *testing.T) { + assert := assert.New(t) + client, err := newsapi.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + assert.NoError(err) + + articles, err := client.Headlines(newsapi.OptQuery("google")) + assert.NoError(err) + assert.NotNil(articles) + + body, _ := json.MarshalIndent(articles, "", " ") + t.Log(string(body)) +} + +func Test_articles_002(t *testing.T) { + assert := assert.New(t) + client, err := newsapi.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + assert.NoError(err) + + articles, err := client.Articles(newsapi.OptQuery("google"), newsapi.OptLimit(1)) + assert.NoError(err) + assert.NotNil(articles) + + body, _ := json.MarshalIndent(articles, "", " ") + t.Log(string(body)) +} diff --git a/pkg/newsapi/client.go b/pkg/newsapi/client.go new file mode 100644 index 0000000..60e3eef --- /dev/null +++ b/pkg/newsapi/client.go @@ -0,0 +1,51 @@ +/* +newsapi implements an API client for NewsAPI (https://newsapi.org/docs) +*/ +package newsapi + +import ( + // Packages + "github.com/mutablelogic/go-client" + "github.com/mutablelogic/go-llm/pkg/tool" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Client struct { + *client.Client +} + +/////////////////////////////////////////////////////////////////////////////// +// GLOBALS + +const ( + endPoint = "https://newsapi.org/v2" +) + +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func New(ApiKey string, opts ...client.ClientOpt) (*Client, error) { + // Create client + client, err := client.New(append(opts, client.OptEndpoint(endPoint), client.OptHeader("X-Api-Key", ApiKey))...) + if err != nil { + return nil, err + } + + // Return the client + return &Client{client}, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (newsapi *Client) RegisterWithToolKit(toolkit *tool.ToolKit) error { + // Register tools + if err := toolkit.Register(&headlines{newsapi}); err != nil { + return err + } + + // Return success + return nil +} diff --git a/pkg/newsapi/client_test.go b/pkg/newsapi/client_test.go new file mode 100644 index 0000000..bf641b5 --- /dev/null +++ b/pkg/newsapi/client_test.go @@ -0,0 +1,31 @@ +package newsapi_test + +import ( + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + newsapi "github.com/mutablelogic/go-llm/pkg/newsapi" + assert "github.com/stretchr/testify/assert" +) + +func Test_client_001(t *testing.T) { + assert := assert.New(t) + client, err := newsapi.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + assert.NoError(err) + assert.NotNil(client) + t.Log(client) +} + +/////////////////////////////////////////////////////////////////////////////// +// ENVIRONMENT + +func GetApiKey(t *testing.T) string { + key := os.Getenv("NEWSAPI_KEY") + if key == "" { + t.Skip("NEWSAPI_KEY not set") + t.SkipNow() + } + return key +} diff --git a/pkg/newsapi/opts.go b/pkg/newsapi/opts.go new file mode 100644 index 0000000..e43c727 --- /dev/null +++ b/pkg/newsapi/opts.go @@ -0,0 +1,114 @@ +package newsapi + +import ( + "fmt" + "net/url" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type opts struct { + Category string `json:"category,omitempty"` + Language string `json:"language,omitempty"` + Country string `json:"country,omitempty"` + Query string `json:"q,omitempty"` + Limit int `json:"pageSize,omitempty"` + Sort string `json:"sortBy,omitempty"` +} + +// Opt is a function which can be used to set options on a request +type Opt func(*opts) error + +/////////////////////////////////////////////////////////////////////////////// +// METHODS + +func (o *opts) Values() url.Values { + result := url.Values{} + if o.Category != "" { + result.Set("category", o.Category) + } + if o.Language != "" { + result.Set("language", o.Language) + } + if o.Country != "" { + result.Set("country", o.Country) + } + if o.Query != "" { + result.Set("q", o.Query) + } + if o.Limit > 0 { + result.Set("pageSize", fmt.Sprint(o.Limit)) + } + if o.Sort != "" { + result.Set("sortBy", o.Sort) + } + return result +} + +/////////////////////////////////////////////////////////////////////////////// +// OPTIONS + +// Set the category +func OptCategory(v string) Opt { + return func(o *opts) error { + o.Category = v + return nil + } +} + +// Set the language +func OptLanguage(v string) Opt { + return func(o *opts) error { + o.Language = v + return nil + } +} + +// Set the country +func OptCountry(v string) Opt { + return func(o *opts) error { + o.Country = v + return nil + } +} + +// Set the query +func OptQuery(v string) Opt { + return func(o *opts) error { + o.Query = v + return nil + } +} + +// Set the number of results +func OptLimit(v int) Opt { + return func(o *opts) error { + o.Limit = v + return nil + } +} + +// Sort for articles by relevancy +func OptSortByRelevancy() Opt { + return func(o *opts) error { + o.Sort = "relevancy" + return nil + } +} + +// Sort for articles by popularity +func OptSortByPopularity() Opt { + return func(o *opts) error { + o.Sort = "popularity" + return nil + } +} + +// Sort for articles by date +func OptSortByDate() Opt { + return func(o *opts) error { + o.Sort = "publishedAt" + return nil + } +} diff --git a/pkg/newsapi/sources.go b/pkg/newsapi/sources.go new file mode 100644 index 0000000..ef7aa4c --- /dev/null +++ b/pkg/newsapi/sources.go @@ -0,0 +1,63 @@ +package newsapi + +import ( + // Packages + "github.com/mutablelogic/go-client" + + // Namespace imports + . "github.com/djthorpe/go-errors" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type Source struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Url string `json:"url,omitempty"` + Category string `json:"category,omitempty"` + Language string `json:"language,omitempty"` + Country string `json:"country,omitempty"` +} + +type respSources struct { + Status string `json:"status"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + Sources []Source `json:"sources"` +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// Sources returns all the models. The options which can be passed are: +// +// OptCategory: The category you would like to get sources for. Possible +// options are business, entertainment, general, health, science, sports, +// technology. +// +// OptLanguage: The language you would like to get sources for +// +// OptCountry: The country you would like to get sources for +func (c *Client) Sources(opt ...Opt) ([]Source, error) { + var response respSources + var query opts + + // Add options + for _, opt := range opt { + if err := opt(&query); err != nil { + return nil, err + } + } + + // Request -> Response + if err := c.Do(nil, &response, client.OptPath("top-headlines/sources"), client.OptQuery(query.Values())); err != nil { + return nil, err + } else if response.Status != "ok" { + return nil, ErrBadParameter.Withf("%s: %s", response.Code, response.Message) + } + + // Return success + return response.Sources, nil +} diff --git a/pkg/newsapi/sources_test.go b/pkg/newsapi/sources_test.go new file mode 100644 index 0000000..d61ac1b --- /dev/null +++ b/pkg/newsapi/sources_test.go @@ -0,0 +1,25 @@ +package newsapi_test + +import ( + "encoding/json" + "os" + "testing" + + // Packages + opts "github.com/mutablelogic/go-client" + newsapi "github.com/mutablelogic/go-llm/pkg/newsapi" + assert "github.com/stretchr/testify/assert" +) + +func Test_sources_001(t *testing.T) { + assert := assert.New(t) + client, err := newsapi.New(GetApiKey(t), opts.OptTrace(os.Stderr, true)) + assert.NoError(err) + + sources, err := client.Sources(newsapi.OptLanguage("en")) + assert.NoError(err) + assert.NotNil(sources) + + body, err := json.MarshalIndent(sources, "", " ") + t.Log(string(body)) +} diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index 733bf58..bb1291c 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -1,6 +1,7 @@ package tool import ( + "fmt" "reflect" "strings" @@ -73,6 +74,14 @@ func paramsFor(params any) ([]ToolParameter, error) { fields := reflect.VisibleFields(rt) result := make([]ToolParameter, 0, len(fields)) for _, field := range fields { + fmt.Println(field.Name, "=>", field.Index) + // Ignore unexported fields + name := field.Tag.Get("json") + if name == "-" { + continue + } + + // Determine parameter if param, err := paramFor(field); err != nil { return nil, err } else { From a5499c61578c5661e391393f6df64713639c5c96 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 09:22:06 +0100 Subject: [PATCH 27/33] Updates --- .DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 0a310b86d24da91bbbf5a4a44af2465bc821af04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}T>S5Z<-bBorYBg&r5Y7ObUI#Y>3w1&ruHr6#1*(3mYvY7eE5v%Zi|;`2DO zy8(+ii`W_1{pNQ!`$6`HF~ZQG6L%(nwi*N z2mJO1o3SaIM9c5rpC)a5r8h|}I+-4UyE)cb?} zdhOVUN5>b}qiOn*$u~_W2ey^$8?4|R6tkMwV4kHin}DavuCfS;0b+m{AO^OZ0dp?c zt?j0PR! z<=_`4&ofwR)a8t;nPD6=bM<)PYIg7omCm@Qk$Pf)7}#W>p-mUh|10=qY9INVC1eo; z#K1pefH#N1(1%5tv-QXF@T?Wko}i&%UWp0_=o^;+FmNAftDuex)FIC^SZc&k(67n? P=^~&Ap^g~%1qQwVOKVAc From 25a30d070dab8709cf6c608666112a580e5efea7 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 09:22:23 +0100 Subject: [PATCH 28/33] Updates --- .gitignore | 20 +-- agent.go | 3 - attachment.go | 43 +++++++ cmd/agent/{generate.go => chat.go} | 2 +- cmd/agent/main.go | 23 ++-- cmd/agent/models.go | 10 ++ model.go | 5 + opt.go | 193 ++++++++++++++++++++++++++++- pkg/agent/agent.go | 26 +--- pkg/agent/opt.go | 77 +----------- pkg/anthropic/client.go | 6 - pkg/anthropic/model.go | 5 + pkg/anthropic/opt.go | 74 +---------- pkg/newsapi/agent.go | 5 +- pkg/newsapi/client.go | 2 +- pkg/ollama/chat.go | 40 ++++-- pkg/ollama/chat_test.go | 9 +- pkg/ollama/embedding.go | 10 +- pkg/ollama/message.go | 4 +- pkg/ollama/model.go | 12 +- pkg/ollama/opt.go | 171 +++++++++++++------------ pkg/ollama/session.go | 13 +- pkg/ollama/session_test.go | 7 +- pkg/tool/tool.go | 78 +++++++++--- pkg/tool/toolkit.go | 12 +- tool.go => toolkit.go | 14 +++ 26 files changed, 513 insertions(+), 351 deletions(-) create mode 100644 attachment.go rename cmd/agent/{generate.go => chat.go} (95%) rename tool.go => toolkit.go (63%) diff --git a/.gitignore b/.gitignore index b9c3011..08ef92e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,12 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins *.exe *.exe~ *.dll *.so *.dylib - -# Test binary, built with `go test -c` *.test - -# Output of the go coverage tool, specifically when used with LiteIDE *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file go.work go.work.sum - -# env file -.env - -# build output +vendor/ build/ +.DS_Store diff --git a/agent.go b/agent.go index 56672ec..b7658dd 100644 --- a/agent.go +++ b/agent.go @@ -11,7 +11,4 @@ type Agent interface { // Return the models Models(context.Context) ([]Model, error) - - // Embedding vector generation - Embedding(context.Context, Model, string, ...Opt) ([]float64, error) } diff --git a/attachment.go b/attachment.go new file mode 100644 index 0000000..c7733c4 --- /dev/null +++ b/attachment.go @@ -0,0 +1,43 @@ +package llm + +import ( + "io" + "os" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +// Attachment for messages +type Attachment struct { + filename string + data []byte +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// ReadAttachment returns an attachment from a reader object. +// It is the responsibility of the caller to close the reader. +func ReadAttachment(r io.Reader) (*Attachment, error) { + var filename string + data, err := io.ReadAll(r) + if err != nil { + return nil, err + } + if f, ok := r.(*os.File); ok { + filename = f.Name() + } + return &Attachment{filename: filename, data: data}, nil +} + +//////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +func (a *Attachment) Filename() string { + return a.filename +} + +func (a *Attachment) Data() []byte { + return a.data +} diff --git a/cmd/agent/generate.go b/cmd/agent/chat.go similarity index 95% rename from cmd/agent/generate.go rename to cmd/agent/chat.go index 792a8e8..5946cc4 100644 --- a/cmd/agent/generate.go +++ b/cmd/agent/chat.go @@ -36,7 +36,7 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { } // Create a session - session := model.Context(agent.WithStream(!cmd.NoStream)) + session := model.Context() // Continue looping until end of input for { diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 382a47c..7154e0e 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -32,9 +32,10 @@ type Globals struct { NewsAPI `embed:"" help:"NewsAPI configuration"` // Context - ctx context.Context - agent llm.Agent - term *Term + ctx context.Context + agent llm.Agent + toolkit *tool.ToolKit + term *Term } type Ollama struct { @@ -53,10 +54,13 @@ type CLI struct { Globals // Agents, Models and Tools - Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` - Models ListModelsCmd `cmd:"" help:"Return a list of models"` + Agents ListAgentsCmd `cmd:"" help:"Return a list of agents"` + Models ListModelsCmd `cmd:"" help:"Return a list of models"` + Tools ListToolsCmd `cmd:"" help:"Return a list of tools"` + + // Commands Download DownloadModelCmd `cmd:"" help:"Download a model"` - Generate GenerateCmd `cmd:"" help:"Generate a response"` + Generate GenerateCmd `cmd:"chat" help:"Start a chat session"` } //////////////////////////////////////////////////////////////////////////////// @@ -104,9 +108,9 @@ func main() { // Make a toolkit toolkit := tool.NewToolKit() - opts = append(opts, agent.WithToolKit(toolkit)) + cli.Globals.toolkit = toolkit - // NewsAPI + // Register NewsAPI if cli.NewsKey != "" { if client, err := newsapi.New(cli.NewsKey, clientopts...); err != nil { cmd.FatalIfErrorf(err) @@ -115,6 +119,9 @@ func main() { } } + // Append the toolkit + opts = append(opts, llm.WithToolKit(toolkit)) + // Create the agent agent, err := agent.New(opts...) cmd.FatalIfErrorf(err) diff --git a/cmd/agent/models.go b/cmd/agent/models.go index ad136ec..1bb96ee 100644 --- a/cmd/agent/models.go +++ b/cmd/agent/models.go @@ -20,6 +20,8 @@ type ListModelsCmd struct { type ListAgentsCmd struct{} +type ListToolsCmd struct{} + type DownloadModelCmd struct { Agent string `arg:"" help:"Agent name"` Model string `arg:"" help:"Model name"` @@ -28,6 +30,14 @@ type DownloadModelCmd struct { //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS +func (cmd *ListToolsCmd) Run(globals *Globals) error { + return runagent(globals, func(ctx context.Context, client llm.Agent) error { + tools := globals.toolkit.Tools(client) + fmt.Println(tools) + return nil + }) +} + func (cmd *ListModelsCmd) Run(globals *Globals) error { return runagent(globals, func(ctx context.Context, client llm.Agent) error { agent, ok := client.(*agent.Agent) diff --git a/model.go b/model.go index 49eac35..221105b 100644 --- a/model.go +++ b/model.go @@ -1,5 +1,7 @@ package llm +import "context" + // An Model can be used to generate a response to a user prompt, // which is passed to an agent. The interaction occurs through // a session context object. @@ -14,4 +16,7 @@ type Model interface { // Convenience method to create a session context object // with a user prompt UserPrompt(string, ...Opt) Context + + // Embedding vector generation + Embedding(context.Context, string, ...Opt) ([]float64, error) } diff --git a/opt.go b/opt.go index 991c6f1..7b101e4 100644 --- a/opt.go +++ b/opt.go @@ -1,7 +1,198 @@ package llm +import ( + "io" + "time" +) + /////////////////////////////////////////////////////////////////////////////// // TYPES // A generic option type, which can set options on an agent or session -type Opt func(any) error +type Opt func(*Opts) error + +// set of options +type Opts struct { + agents map[string]Agent // Set of agents + toolkit ToolKit // Toolkit for tools + callback func(Context) // Streaming callback + attachments []*Attachment // Attachments + options map[string]any // Additional options +} + +//////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +// ApplyOpts returns a structure of options +func ApplyOpts(opts ...Opt) (*Opts, error) { + o := new(Opts) + o.agents = make(map[string]Agent) + o.options = make(map[string]any) + for _, opt := range opts { + if err := opt(o); err != nil { + return nil, err + } + } + return o, nil +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - PROPERTIES + +// Return the set of tools +func (o *Opts) ToolKit() ToolKit { + return o.toolkit +} + +// Return the stream function +func (o *Opts) StreamFn() func(Context) { + return o.callback +} + +// Return the array of registered agents +func (o *Opts) Agents() []Agent { + result := make([]Agent, 0, len(o.agents)) + for _, agent := range o.agents { + result = append(result, agent) + } + return result +} + +// Return attachments +func (o *Opts) Attachments() []*Attachment { + return o.attachments +} + +// Set an option value +func (o *Opts) Set(key string, value any) { + o.options[key] = value +} + +// Get an option value +func (o *Opts) Get(key string) any { + if value, exists := o.options[key]; exists { + return value + } + return nil +} + +// Has an option value +func (o *Opts) Has(key string) bool { + _, exists := o.options[key] + return exists +} + +// Get an option value as a string +func (o *Opts) GetString(key string) string { + if value, exists := o.options[key]; exists { + if v, ok := value.(string); ok { + return v + } + } + return "" +} + +// Get an option value as a boolean +func (o *Opts) GetBool(key string) bool { + if value, exists := o.options[key]; exists { + if v, ok := value.(bool); ok { + return v + } + } + return false +} + +// Get an option value as a duration +func (o *Opts) GetDuration(key string) time.Duration { + if value, exists := o.options[key]; exists { + if v, ok := value.(time.Duration); ok { + return v + } + } + return 0 +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS - SET OPTIONS + +// Set toolkit of tools +func WithToolKit(toolkit ToolKit) Opt { + return func(o *Opts) error { + o.toolkit = toolkit + return nil + } +} + +// Set chat streaming function +func WithStream(fn func(Context)) Opt { + return func(o *Opts) error { + o.callback = fn + return nil + } +} + +// Set agent +func WithAgent(agent Agent) Opt { + return func(o *Opts) error { + // Check parameters + if agent == nil { + return ErrBadParameter.With("withAgent") + } + + // Add agent + name := agent.Name() + if _, exists := o.agents[name]; exists { + return ErrConflict.Withf("Agent %q already exists", name) + } else { + o.agents[name] = agent + } + + // Return success + return nil + } + +} + +// Create an attachment +func WithAttachment(r io.Reader) Opt { + return func(o *Opts) error { + if attachment, err := ReadAttachment(r); err != nil { + return err + } else { + o.attachments = append(o.attachments, attachment) + } + return nil + } +} + +// The temperature of the model. Increasing the temperature will make the model answer more creatively. +func WithTemperature(v float64) Opt { + return func(o *Opts) error { + if v < 0.0 || v > 1.0 { + return ErrBadParameter.With("temperature must be between 0.0 and 1.0") + } + o.Set("temperature", v) + return nil + } +} + +// Works together with top-k. A higher value (e.g., 0.95) will lead to more diverse text, while +// a lower value (e.g., 0.5) will generate more focused and conservative text. +func WithTopP(v float64) Opt { + return func(o *Opts) error { + if v < 0.0 || v > 1.0 { + return ErrBadParameter.With("top_p must be between 0.0 and 1.0") + } + o.Set("top_p", v) + return nil + } +} + +// Reduces the probability of generating nonsense. A higher value (e.g. 100) will give more +// diverse answers, while a lower value (e.g. 10) will be more conservative. +func WithTopK(v uint) Opt { + return func(o *Opts) error { + o.Set("top_k", v) + return nil + } +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 2c10a77..c91aa4b 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -15,7 +15,7 @@ import ( // TYPES type Agent struct { - *opt + *llm.Opts } type model struct { @@ -31,10 +31,10 @@ var _ llm.Agent = (*Agent)(nil) // Return a new agent, composed of a series of agents and tools func New(opts ...llm.Opt) (*Agent, error) { agent := new(Agent) - if opt, err := apply(opts...); err != nil { + if opts, err := llm.ApplyOpts(opts...); err != nil { return nil, err } else { - agent.opt = opt + agent.Opts = opts } // Return success @@ -55,22 +55,13 @@ func (m model) String() string { /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -// Return a list of agent names -func (a *Agent) Agents() []llm.Agent { - var result []llm.Agent - for _, v := range a.agents { - result = append(result, v) - } - return result -} - // Return a list of tool names func (a *Agent) Tools() []string { - if a.toolkit == nil { + if a.ToolKit() == nil { return nil } var result []string - for _, t := range a.toolkit.Tools(a) { + for _, t := range a.ToolKit().Tools(a) { result = append(result, t.Name()) } return result @@ -79,7 +70,7 @@ func (a *Agent) Tools() []string { // Return a comma-separated list of agent names func (a *Agent) Name() string { var keys []string - for key := range a.agents { + for key := range a.Agents() { keys = append(keys, key) } return strings.Join(keys, ",") @@ -155,11 +146,6 @@ func (a *Agent) GetModel(ctx context.Context, name string, agents ...string) (ll return nil, result } -// Embedding vector generation -func (a *Agent) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { - return nil, llm.ErrNotImplemented -} - /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index 2b870f7..f7b3d1f 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -1,43 +1,14 @@ package agent import ( - "fmt" // Packages client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" ollama "github.com/mutablelogic/go-llm/pkg/ollama" - "github.com/mutablelogic/go-llm/pkg/tool" ) -//////////////////////////////////////////////////////////////////////////////// -// TYPES - -type opt struct { - agents map[string]llm.Agent - toolkit *tool.ToolKit - - // Translated options for each agent implementation - ollama []llm.Opt - anthropic []llm.Opt -} - -//////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -// Apply options -func apply(opts ...llm.Opt) (*opt, error) { - o := new(opt) - o.agents = make(map[string]llm.Agent) - for _, opt := range opts { - if err := opt(o); err != nil { - return nil, err - } - } - return o, nil -} - //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -47,7 +18,7 @@ func WithOllama(endpoint string, opts ...client.ClientOpt) llm.Opt { if err != nil { return err } else { - return o.(*opt).withAgent(client) + return llm.WithAgent(client) } } } @@ -58,51 +29,7 @@ func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { if err != nil { return err } else { - return o.(*opt).withAgent(client) + return llm.WithAgent(client) } } } - -// Append toolkit -func WithToolKit(toolkit *tool.ToolKit) llm.Opt { - return func(o any) error { - o.(*opt).toolkit = toolkit - o.(*opt).ollama = append(o.(*opt).ollama, ollama.WithToolKit(toolkit)) - o.(*opt).anthropic = append(o.(*opt).anthropic, anthropic.WithToolKit(toolkit)) - return nil - } -} - -// Set streaming function -func WithStream(v bool) llm.Opt { - return func(o any) error { - o.(*opt).ollama = append(o.(*opt).ollama, ollama.WithStream(func(r *ollama.Response) { - fmt.Println("OLLAMA STREAM", r) - })) - o.(*opt).anthropic = append(o.(*opt).anthropic, anthropic.WithStream(func(r *anthropic.Response) { - fmt.Println("ANTHROPIC STREAM", r) - })) - return nil - } -} - -//////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func (o *opt) withAgent(agent llm.Agent) error { - // Check parameters - if agent == nil || o.agents == nil { - return llm.ErrBadParameter.With("withAgent") - } - - // Add agent - name := agent.Name() - if _, exists := o.agents[name]; exists { - return llm.ErrConflict.Withf("Agent %q already exists", name) - } else { - o.agents[name] = agent - } - - // Return success - return nil -} diff --git a/pkg/anthropic/client.go b/pkg/anthropic/client.go index 2c9292d..53299bd 100644 --- a/pkg/anthropic/client.go +++ b/pkg/anthropic/client.go @@ -4,7 +4,6 @@ anthropic implements an API client for anthropic (https://docs.anthropic.com/en/ package anthropic import ( - "context" // Packages client "github.com/mutablelogic/go-client" @@ -53,8 +52,3 @@ func New(ApiKey string, opts ...client.ClientOpt) (*Client, error) { func (*Client) Name() string { return defaultName } - -// Embedding vector generation - not supported on Anthropic -func (*Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { - return nil, llm.ErrNotImplemented -} diff --git a/pkg/anthropic/model.go b/pkg/anthropic/model.go index 7b7511f..288cb47 100644 --- a/pkg/anthropic/model.go +++ b/pkg/anthropic/model.go @@ -89,3 +89,8 @@ func (anthropic *Client) ListModels(ctx context.Context) ([]llm.Model, error) { func (model *model) Name() string { return model.ModelMeta.Name } + +// Embedding vector generation - not supported on Anthropic +func (*model) Embedding(context.Context, string, ...llm.Opt) ([]float64, error) { + return nil, llm.ErrNotImplemented +} diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index bcbbb78..4e624cf 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -30,46 +30,13 @@ type optmetadata struct { User string `json:"user_id,omitempty"` } -//////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -func apply(opts ...llm.Opt) (*opt, error) { - o := new(opt) - for _, opt := range opts { - if err := opt(o); err != nil { - return nil, err - } - } - return o, nil -} - -//////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func (o *opt) tools(agent llm.Agent) []llm.Tool { - if o.toolkit == nil { - return nil - } else { - return o.toolkit.Tools(agent) - } -} - //////////////////////////////////////////////////////////////////////////////// // OPTIONS -// Messages: Stream the response as it is received. -func WithStream(fn func(*Response)) llm.Opt { - return func(o any) error { - o.(*opt).Stream = true - o.(*opt).callback = fn - return nil - } -} - // Messages: Attach data to the request, which can be cached on the server-side // and cited the response. -func WithData(r io.Reader, ephemeral, citations bool) llm.Opt { - return func(o any) error { +func WithAttachment(r io.Reader, ephemeral, citations bool) llm.Opt { + return func(o *llm.Opt) error { attachment, err := ReadContent(r, ephemeral, citations) if err != nil { return err @@ -79,16 +46,6 @@ func WithData(r io.Reader, ephemeral, citations bool) llm.Opt { } } -func WithTemperature(v float64) llm.Opt { - return func(o any) error { - if v < 0.0 || v > 1.0 { - return llm.ErrBadParameter.With("temperature must be between 0.0 and 1.0") - } - o.(*opt).Temperature = v - return nil - } -} - func WithSystem(v string) llm.Opt { return func(o any) error { o.(*opt).System = v @@ -116,30 +73,3 @@ func WithStopSequences(v ...string) llm.Opt { return nil } } - -func WithTopP(v float64) llm.Opt { - return func(o any) error { - if v < 0.0 || v > 1.0 { - return llm.ErrBadParameter.With("top_p must be between 0.0 and 1.0") - } - o.(*opt).TopP = v - return nil - } -} - -func WithTopK(v uint) llm.Opt { - return func(o any) error { - o.(*opt).TopK = v - return nil - } -} - -// Messages: Append a toolkit to the request -func WithToolKit(v *tool.ToolKit) llm.Opt { - return func(o any) error { - if v != nil { - o.(*opt).toolkit = v - } - return nil - } -} diff --git a/pkg/newsapi/agent.go b/pkg/newsapi/agent.go index abc3cfb..cea7991 100644 --- a/pkg/newsapi/agent.go +++ b/pkg/newsapi/agent.go @@ -11,7 +11,8 @@ import ( // TYPES type headlines struct { - *Client `json:"-"` + *Client `json:"-"` + CountryCode string `json:"country_code,omitempty" help:"The two-letter countrycode to return headlines for"` } var _ llm.Tool = (*headlines)(nil) @@ -24,7 +25,7 @@ func (headlines) Name() string { } func (headlines) Description() string { - return "Return the current news headlines" + return "Return the current news headlines, optionally for a specific country" } func (headlines *headlines) Run(ctx context.Context) (any, error) { diff --git a/pkg/newsapi/client.go b/pkg/newsapi/client.go index 60e3eef..1b66d5f 100644 --- a/pkg/newsapi/client.go +++ b/pkg/newsapi/client.go @@ -42,7 +42,7 @@ func New(ApiKey string, opts ...client.ClientOpt) (*Client, error) { func (newsapi *Client) RegisterWithToolKit(toolkit *tool.ToolKit) error { // Register tools - if err := toolkit.Register(&headlines{newsapi}); err != nil { + if err := toolkit.Register(&headlines{newsapi, ""}); err != nil { return err } diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index e55be83..8bbfeaa 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -58,8 +58,7 @@ type reqChat struct { } func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm.Opt) (*Response, error) { - // Apply options - opt, err := apply(opts...) + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } @@ -68,11 +67,11 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. req, err := client.NewJSONRequest(reqChat{ Model: prompt.(*session).model.Name(), Messages: prompt.(*session).seq, - Tools: opt.tools(ollama), - Format: opt.format, - Options: opt.options, - Stream: opt.stream, - KeepAlive: opt.keepalive, + Tools: optTools(ollama, opt), + Format: optFormat(opt), + Options: optOptions(opt), + Stream: optStream(opt), + KeepAlive: optKeepAlive(opt), }) if err != nil { return nil, err @@ -95,8 +94,9 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. } } - if opt.chatcallback != nil { - opt.chatcallback(&response) + //Call the chat callback + if fn := opt.StreamFn(); fn != nil { + fn(&response) } return nil })); err != nil { @@ -104,9 +104,29 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. } // We return the delta or the response - if opt.stream { + if optStream(opt) { return &response, nil } else { return &delta, nil } } + +func (response Response) Role() string { + return response.Message.Role +} + +func (response Response) Text() string { + return response.Message.Content +} + +func (response Response) ToolCalls() []ToolCall { + return response.Message.ToolCalls +} + +func (response Response) FromUser(context.Context, string, ...llm.Opt) error { + return llm.ErrNotImplemented +} + +func (response Response) FromTool(context.Context, string, any, ...llm.Opt) error { + return llm.ErrNotImplemented +} diff --git a/pkg/ollama/chat_test.go b/pkg/ollama/chat_test.go index 0004818..cbc77c5 100644 --- a/pkg/ollama/chat_test.go +++ b/pkg/ollama/chat_test.go @@ -9,8 +9,9 @@ import ( // Packages opts "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" ollama "github.com/mutablelogic/go-llm/pkg/ollama" - "github.com/mutablelogic/go-llm/pkg/tool" + tool "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -30,7 +31,7 @@ func Test_chat_001(t *testing.T) { t.Run("ChatStream", func(t *testing.T) { assert := assert.New(t) - response, err := client.Chat(context.TODO(), model.UserPrompt("why is the sky blue?"), ollama.WithStream(func(stream *ollama.Response) { + response, err := client.Chat(context.TODO(), model.UserPrompt("why is the sky blue?"), llm.WithStream(func(stream llm.Context) { t.Log(stream) })) if !assert.NoError(err) { @@ -73,7 +74,7 @@ func Test_chat_002(t *testing.T) { assert := assert.New(t) response, err := client.Chat(context.TODO(), model.UserPrompt("what is the weather in berlin?"), - ollama.WithToolKit(toolkit), + llm.WithToolKit(toolkit), ) if !assert.NoError(err) { t.FailNow() @@ -107,7 +108,7 @@ func Test_chat_003(t *testing.T) { defer f.Close() response, err := client.Chat(context.TODO(), - model.UserPrompt("describe this photo to me", ollama.WithData(f)), + model.UserPrompt("describe this photo to me", llm.WithAttachment(f)), ) if !assert.NoError(err) { t.FailNow() diff --git a/pkg/ollama/embedding.go b/pkg/ollama/embedding.go index 1c69e07..ceae604 100644 --- a/pkg/ollama/embedding.go +++ b/pkg/ollama/embedding.go @@ -57,7 +57,7 @@ type reqEmbedding struct { func (ollama *Client) GenerateEmbedding(ctx context.Context, name string, prompt []string, opts ...llm.Opt) (*EmbeddingMeta, error) { // Apply options - opt, err := apply(opts...) + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } @@ -71,9 +71,9 @@ func (ollama *Client) GenerateEmbedding(ctx context.Context, name string, prompt req, err := client.NewJSONRequest(reqEmbedding{ Model: name, Input: prompt, - Truncate: opt.truncate, - KeepAlive: opt.keepalive, - Options: opt.options, + Truncate: optTruncate(opt), + KeepAlive: optKeepAlive(opt), + Options: optOptions(opt), }) if err != nil { return nil, err @@ -90,6 +90,6 @@ func (ollama *Client) GenerateEmbedding(ctx context.Context, name string, prompt } // Embedding vector generation -func (ollama *Client) Embedding(context.Context, llm.Model, string, ...llm.Opt) ([]float64, error) { +func (model *model) Embedding(context.Context, string, ...llm.Opt) ([]float64, error) { return nil, llm.ErrNotImplemented } diff --git a/pkg/ollama/message.go b/pkg/ollama/message.go index ee9e81a..53efe76 100644 --- a/pkg/ollama/message.go +++ b/pkg/ollama/message.go @@ -1,6 +1,8 @@ package ollama -import llm "github.com/mutablelogic/go-llm" +import ( + llm "github.com/mutablelogic/go-llm" +) /////////////////////////////////////////////////////////////////////////////// // TYPES diff --git a/pkg/ollama/model.go b/pkg/ollama/model.go index 991393e..246d68d 100644 --- a/pkg/ollama/model.go +++ b/pkg/ollama/model.go @@ -211,7 +211,7 @@ func (ollama *Client) PullModel(ctx context.Context, name string, opts ...llm.Op } // Apply options - opt, err := apply(opts...) + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } @@ -219,8 +219,8 @@ func (ollama *Client) PullModel(ctx context.Context, name string, opts ...llm.Op // Request req, err := client.NewJSONRequest(reqPullModel{ Model: name, - Stream: opt.stream, - Insecure: opt.insecure, + Stream: optPullStatus(opt) != nil, + Insecure: optInsecure(opt), }) if err != nil { return nil, err @@ -229,8 +229,10 @@ func (ollama *Client) PullModel(ctx context.Context, name string, opts ...llm.Op // Response var response PullStatus if err := ollama.DoWithContext(ctx, req, &response, client.OptPath("pull"), client.OptNoTimeout(), client.OptJsonStreamCallback(func(v any) error { - if v, ok := v.(*PullStatus); ok && opt.pullcallback != nil { - opt.pullcallback(v) + if v, ok := v.(*PullStatus); ok && v != nil { + if fn := optPullStatus(opt); fn != nil { + fn(v) + } } return nil })); err != nil { diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go index 9af64c5..7f7a1e1 100644 --- a/pkg/ollama/opt.go +++ b/pkg/ollama/opt.go @@ -1,139 +1,134 @@ package ollama import ( - "io" "time" // Packages llm "github.com/mutablelogic/go-llm" - "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// -// TYPES - -type opt struct { - format string - stream bool - pullcallback func(*PullStatus) - chatcallback func(*Response) - insecure bool - truncate *bool - keepalive *time.Duration - options map[string]any - data []Data - toolkit *tool.ToolKit // Toolkit for tools -} - -//////////////////////////////////////////////////////////////////////////////// -// LIFECYCLE - -func apply(opts ...llm.Opt) (*opt, error) { - o := new(opt) - o.options = make(map[string]any) - for _, opt := range opts { - if err := opt(o); err != nil { - return nil, err - } - } - return o, nil -} - -//////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func (o *opt) tools(agent llm.Agent) []ToolFunction { - if o.toolkit == nil { - return nil - } - var result []ToolFunction - for _, t := range o.toolkit.Tools(agent) { - result = append(result, ToolFunction{Type: "function", Function: t}) - } - return result -} - -//////////////////////////////////////////////////////////////////////////////// -// OPTIONS +// PUBLIC METHODS // Pull Model: Allow insecure connections for pulling models. func WithInsecure() llm.Opt { - return func(o any) error { - o.(*opt).insecure = true + return func(o *llm.Opts) error { + o.Set("insecure", true) return nil } } // Embeddings: Does not truncate the end of each input to fit within context length. Returns error if context length is exceeded. func WithTruncate(v bool) llm.Opt { - return func(o any) error { - o.(*opt).truncate = &v + return func(o *llm.Opts) error { + o.Set("truncate", v) return nil } } // Embeddings & Chat: Controls how long the model will stay loaded into memory following the request. func WithKeepAlive(v time.Duration) llm.Opt { - return func(o any) error { - o.(*opt).keepalive = &v + return func(o *llm.Opts) error { + if v <= 0 { + return llm.ErrBadParameter.With("keepalive must be greater than zero") + } + o.Set("keepalive", v) return nil } } // Pull Model: Stream the response as it is received. func WithPullStatus(fn func(*PullStatus)) llm.Opt { - return func(o any) error { - if fn == nil { - o.(*opt).stream = false - o.(*opt).pullcallback = nil - } else { - o.(*opt).stream = true - o.(*opt).pullcallback = fn - } + return func(o *llm.Opts) error { + o.Set("pullstatus", fn) return nil } } -// Chat: Stream the response as it is received. -func WithStream(fn func(*Response)) llm.Opt { - return func(o any) error { - if fn == nil { - return llm.ErrBadParameter.With("callback required") +// Embeddings & Chat: model-specific options. +func WithOption(key string, value any) llm.Opt { + return func(o *llm.Opts) error { + if opts, ok := o.Get("options").(map[string]any); !ok { + o.Set("options", map[string]any{key: value}) + } else { + opts[key] = value } - o.(*opt).stream = true - o.(*opt).chatcallback = fn return nil } } -// Chat: Append a toolkit to the request -func WithToolKit(v *tool.ToolKit) llm.Opt { - return func(o any) error { - if v != nil { - o.(*opt).toolkit = v - } +//////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func optInsecure(opts *llm.Opts) bool { + return opts.GetBool("insecure") +} + +func optTruncate(opts *llm.Opts) *bool { + if !opts.Has("truncate") { return nil } + v := opts.GetBool("truncate") + return &v } -// Embeddings & Chat: model-specific options. -func WithOption(key string, value any) llm.Opt { - return func(o any) error { - if value != nil && key != "" { - o.(*opt).options[key] = value - } +func optPullStatus(opts *llm.Opts) func(*PullStatus) { + if fn, ok := opts.Get("pullstatus").(func(*PullStatus)); ok && fn != nil { + return fn + } + return nil +} + +func optTools(agent *Client, opts *llm.Opts) []ToolFunction { + toolkit := opts.ToolKit() + if toolkit == nil { return nil } + tools := toolkit.Tools(agent) + result := make([]ToolFunction, 0, len(tools)) + for _, tool := range tools { + result = append(result, ToolFunction{ + Type: "function", + Function: tool, + }) + } + return result +} + +func optFormat(opts *llm.Opts) string { + return opts.GetString("format") } -// Chat: attach data. -func WithData(r io.Reader) llm.Opt { - return func(o any) error { - data, err := io.ReadAll(r) - if err != nil { - return err +func optOptions(opts *llm.Opts) map[string]any { + result := make(map[string]any) + if o, ok := opts.Get("options").(map[string]any); ok { + for k, v := range o { + result[k] = v } - o.(*opt).data = append(o.(*opt).data, data) - return nil } + + // copy across temperature, top_p and top_k + if opts.Has("temperature") { + result["temperature"] = opts.Get("temperature") + } + if opts.Has("top_p") { + result["top_p"] = opts.Get("top_p") + } + if opts.Has("top_k") { + result["top_k"] = opts.Get("top_k") + } + + // Return result + return result +} + +func optStream(opts *llm.Opts) bool { + return opts.StreamFn() != nil +} + +func optKeepAlive(opts *llm.Opts) *time.Duration { + if v := opts.GetDuration("keepalive"); v > 0 { + return &v + } + return nil } diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 6c1987e..78d0d1d 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -155,8 +155,8 @@ func (session *session) ToolCalls() []llm.ToolCall { // PRIVATE METHODS func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { - // Apply options - opt, err := apply(opts...) + // Apply options for attachments + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } @@ -165,9 +165,12 @@ func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { var meta MessageMeta meta.Role = "user" meta.Content = prompt - if len(opt.data) > 0 { - meta.Images = make([]Data, len(opt.data)) - copy(meta.Images, opt.data) + + if attachments := opt.Attachments(); len(attachments) > 0 { + meta.Images = make([]Data, len(attachments)) + for i, attachment := range attachments { + meta.Images[i] = attachment.Data() + } } // Return success diff --git a/pkg/ollama/session_test.go b/pkg/ollama/session_test.go index ac4a916..e4df9d4 100644 --- a/pkg/ollama/session_test.go +++ b/pkg/ollama/session_test.go @@ -7,6 +7,7 @@ import ( // Packages opts "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" ollama "github.com/mutablelogic/go-llm/pkg/ollama" "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" @@ -27,7 +28,7 @@ func Test_session_001(t *testing.T) { // Session with a single user prompt - streaming t.Run("stream", func(t *testing.T) { assert := assert.New(t) - session := model.Context(ollama.WithStream(func(stream *ollama.Response) { + session := model.Context(llm.WithStream(func(stream llm.Context) { t.Log("SESSION DELTA", stream) })) assert.NotNil(session) @@ -77,10 +78,10 @@ func Test_session_002(t *testing.T) { t.Run("toolcall", func(t *testing.T) { assert := assert.New(t) - session := model.Context(ollama.WithToolKit(toolkit)) + session := model.Context(llm.WithToolKit(toolkit)) assert.NotNil(session) - err = session.FromUser(context.TODO(), "What is today's weather?") + err = session.FromUser(context.TODO(), "What is today's weather in Berlin?", llm.WithTemperature(0.5)) if !assert.NoError(err) { t.FailNow() } diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index bb1291c..aa8fdd5 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -1,7 +1,7 @@ package tool import ( - "fmt" + "encoding/json" "reflect" "strings" @@ -43,6 +43,17 @@ type ToolParameter struct { index []int // Field index into prototype for setting a field } +//////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (t tool) String() string { + data, err := json.MarshalIndent(t.ToolMeta, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS @@ -58,7 +69,7 @@ func (t tool) Description() string { // PRIVATE METHODS // Return tool parameters from a struct -func paramsFor(params any) ([]ToolParameter, error) { +func paramsFor(root []int, params any) ([]ToolParameter, error) { if params == nil { return []ToolParameter{}, nil } @@ -70,19 +81,38 @@ func paramsFor(params any) ([]ToolParameter, error) { return nil, llm.ErrBadParameter.With("params must be a struct") } + return paramsForStruct(root, rt) +} + +func paramsForStruct(root []int, rt reflect.Type) ([]ToolParameter, error) { + result := make([]ToolParameter, 0, rt.NumField()) + // Iterate over fields - fields := reflect.VisibleFields(rt) - result := make([]ToolParameter, 0, len(fields)) - for _, field := range fields { - fmt.Println(field.Name, "=>", field.Index) + for i := 0; i < rt.NumField(); i++ { + field := rt.Field(i) + // Ignore unexported fields - name := field.Tag.Get("json") - if name == "-" { + name := fieldName(field) + if name == "" { + continue + } + + // Recurse into struct + ft := field.Type + if ft.Kind() == reflect.Ptr { + ft = field.Type.Elem() + } + if ft.Kind() == reflect.Struct { + if param, err := paramsForStruct(append(root, field.Index...), ft); err != nil { + return nil, err + } else { + result = append(result, param...) + } continue } // Determine parameter - if param, err := paramFor(field); err != nil { + if param, err := paramFor(root, field); err != nil { return nil, err } else { result = append(result, param) @@ -94,13 +124,7 @@ func paramsFor(params any) ([]ToolParameter, error) { } // Return tool parameters from a struct field -func paramFor(field reflect.StructField) (ToolParameter, error) { - // Name - name := field.Tag.Get("name") - if name == "" { - name = field.Name - } - +func paramFor(root []int, field reflect.StructField) (ToolParameter, error) { // Type typ, err := paramType(field) if err != nil { @@ -118,15 +142,33 @@ func paramFor(field reflect.StructField) (ToolParameter, error) { // Return success return ToolParameter{ - Name: field.Name, + Name: fieldName(field), Type: typ, Description: field.Tag.Get("help"), Enum: enum, required: required, - index: field.Index, + index: append(root, field.Index...), }, nil } +// Return the name field, or empty name if field +// should be ignored +func fieldName(field reflect.StructField) string { + name, exists := field.Tag.Lookup("name") + if !exists { + name, exists = field.Tag.Lookup("json") + if names := strings.Split(name, ","); exists && len(names) > 0 { + name = names[0] + } + } + if !exists { + name = field.Name + } else if name == "-" { + return "" + } + return name +} + var ( typeString = reflect.TypeOf("") typeUint = reflect.TypeOf(uint(0)) diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go index e4247c1..f684741 100644 --- a/pkg/tool/toolkit.go +++ b/pkg/tool/toolkit.go @@ -1,12 +1,12 @@ package tool import ( - // Packages "context" "errors" "fmt" "sync" + // Packages llm "github.com/mutablelogic/go-llm" ) @@ -18,6 +18,8 @@ type ToolKit struct { functions map[string]tool } +var _ llm.ToolKit = (*ToolKit)(nil) + //////////////////////////////////////////////////////////////////////////////// // LIFECYCLE @@ -68,7 +70,7 @@ func (kit *ToolKit) Register(v llm.Tool) error { } // Determine parameters - toolparams, err := paramsFor(v) + toolparams, err := paramsFor(nil, v) if err != nil { return err } @@ -103,7 +105,7 @@ func (kit *ToolKit) Register(v llm.Tool) error { } // Run calls a tool in the toolkit -func (kit *ToolKit) Run(ctx context.Context, calls []llm.ToolCall) error { +func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) error { var wg sync.WaitGroup var result error @@ -114,7 +116,7 @@ func (kit *ToolKit) Run(ctx context.Context, calls []llm.ToolCall) error { go func(call llm.ToolCall) { defer wg.Done() - fmt.Println(call) + fmt.Println("Running ", call) // Get the tool and run it name := call.Name() @@ -122,7 +124,7 @@ func (kit *ToolKit) Run(ctx context.Context, calls []llm.ToolCall) error { result = errors.Join(result, llm.ErrNotFound.Withf("tool %q not found", name)) } else if err := call.Decode(kit.functions[name].Tool); err != nil { result = errors.Join(result, err) - } else if out, err := kit.functions[name].Run(ctx); err != nil { + } else if out, err := kit.functions[name].Tool.Run(ctx); err != nil { result = errors.Join(result, err) } else { // TODO: Return the result alongside the call diff --git a/tool.go b/toolkit.go similarity index 63% rename from tool.go rename to toolkit.go index 77c1de0..2fd49e0 100644 --- a/tool.go +++ b/toolkit.go @@ -7,6 +7,19 @@ import ( //////////////////////////////////////////////////////////////////////////////// // TYPES +// ToolKit is a collection of tools +type ToolKit interface { + // Register a tool in the toolkit + Register(Tool) error + + // Return all the tools + Tools(Agent) []Tool + + // Run the tool calls in parallel + // TODO: Return tool results + Run(context.Context, ...ToolCall) error +} + // Definition of a tool type Tool interface { // The name of the tool @@ -16,6 +29,7 @@ type Tool interface { Description() string // Run the tool with a deadline and return the result + // TODO: Change 'any' to ToolResult Run(context.Context) (any, error) } From 5096045ad23c3bfcbafc589343ea1b135fc48e99 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 11:06:23 +0100 Subject: [PATCH 29/33] Updated tool calling --- cmd/agent/chat.go | 30 ++++- cmd/agent/main.go | 2 +- context.go | 9 +- opt.go | 48 ++++++- pkg/agent/agent.go | 65 +++++----- pkg/agent/opt.go | 9 +- pkg/anthropic/client.go | 1 - pkg/anthropic/message.go | 56 +++----- pkg/anthropic/messages.go | 78 +++++------ pkg/anthropic/messages_test.go | 15 ++- pkg/anthropic/opt.go | 121 ++++++++++++------ pkg/anthropic/session.go | 16 ++- pkg/anthropic/session_test.go | 9 +- pkg/ollama/chat.go | 21 ++- pkg/ollama/opt.go | 13 +- pkg/tool/call.go | 8 +- pkg/tool/{ => old}/tool.go_old | 0 .../tool.go_old => tool/old/tool.go_old_old} | 0 pkg/tool/{ => old}/tool_test.go_old | 0 pkg/tool/result.go | 46 +++++++ pkg/tool/toolkit.go | 19 ++- toolkit.go | 14 +- 22 files changed, 371 insertions(+), 209 deletions(-) rename pkg/tool/{ => old}/tool.go_old (100%) rename pkg/{anthropic/tool.go_old => tool/old/tool.go_old_old} (100%) rename pkg/tool/{ => old}/tool_test.go_old (100%) create mode 100644 pkg/tool/result.go diff --git a/cmd/agent/chat.go b/cmd/agent/chat.go index 5946cc4..775f2dd 100644 --- a/cmd/agent/chat.go +++ b/cmd/agent/chat.go @@ -15,15 +15,16 @@ import ( //////////////////////////////////////////////////////////////////////////////// // TYPES -type GenerateCmd struct { +type ChatCmd struct { Model string `arg:"" help:"Model name"` NoStream bool `flag:"nostream" help:"Disable streaming"` + System string `flag:"system" help:"Set the system prompt"` } //////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (cmd *GenerateCmd) Run(globals *Globals) error { +func (cmd *ChatCmd) Run(globals *Globals) error { return runagent(globals, func(ctx context.Context, client llm.Agent) error { // Get the model a, ok := client.(*agent.Agent) @@ -35,8 +36,22 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { return err } + // Set the options + opts := []llm.Opt{} + if !cmd.NoStream { + opts = append(opts, llm.WithStream(func(cc llm.ContextContent) { + fmt.Println(cc) + })) + } + if cmd.System != "" { + opts = append(opts, llm.WithSystemPrompt(cmd.System)) + } + if globals.toolkit != nil { + opts = append(opts, llm.WithToolKit(globals.toolkit)) + } + // Create a session - session := model.Context() + session := model.Context(opts...) // Continue looping until end of input for { @@ -57,7 +72,16 @@ func (cmd *GenerateCmd) Run(globals *Globals) error { if err := session.FromUser(ctx, input); err != nil { return err } + fmt.Println(session.Text()) + + // If there are tool calls, then do these + calls := session.ToolCalls() + if results, err := globals.toolkit.Run(ctx, calls...); err != nil { + return err + } else { + fmt.Println(results) + } } }) } diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7154e0e..3030550 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -60,7 +60,7 @@ type CLI struct { // Commands Download DownloadModelCmd `cmd:"" help:"Download a model"` - Generate GenerateCmd `cmd:"chat" help:"Start a chat session"` + Chat ChatCmd `cmd:"" help:"Start a chat session"` } //////////////////////////////////////////////////////////////////////////////// diff --git a/context.go b/context.go index e391f15..889a7ba 100644 --- a/context.go +++ b/context.go @@ -5,8 +5,8 @@ import "context" ////////////////////////////////////////////////////////////////// // TYPES -// Context is fed to the agent to generate a response -type Context interface { +// ContextContent is the content of the last context message +type ContextContent interface { // Return the current session role, which can be system, assistant, user, tool, tool_result, ... Role() string @@ -15,6 +15,11 @@ type Context interface { // Return the current session tool calls, or empty if no tool calls were made ToolCalls() []ToolCall +} + +// Context is fed to the agent to generate a response +type Context interface { + ContextContent // Generate a response from a user prompt (with attachments and // other empheral options diff --git a/opt.go b/opt.go index 7b101e4..df91705 100644 --- a/opt.go +++ b/opt.go @@ -13,11 +13,12 @@ type Opt func(*Opts) error // set of options type Opts struct { - agents map[string]Agent // Set of agents - toolkit ToolKit // Toolkit for tools - callback func(Context) // Streaming callback - attachments []*Attachment // Attachments - options map[string]any // Additional options + agents map[string]Agent // Set of agents + toolkit ToolKit // Toolkit for tools + callback func(ContextContent) // Streaming callback + attachments []*Attachment // Attachments + system string // System prompt + options map[string]any // Additional options } //////////////////////////////////////////////////////////////////////////////// @@ -45,10 +46,15 @@ func (o *Opts) ToolKit() ToolKit { } // Return the stream function -func (o *Opts) StreamFn() func(Context) { +func (o *Opts) StreamFn() func(ContextContent) { return o.callback } +// Return the system prompt +func (o *Opts) SystemPrompt() string { + return o.system +} + // Return the array of registered agents func (o *Opts) Agents() []Agent { result := make([]Agent, 0, len(o.agents)) @@ -102,6 +108,26 @@ func (o *Opts) GetBool(key string) bool { return false } +// Get an option value as an unsigned integer +func (o *Opts) GetUint64(key string) uint64 { + if value, exists := o.options[key]; exists { + if v, ok := value.(uint64); ok { + return v + } + } + return 0 +} + +// Get an option value as a float64 +func (o *Opts) GetFloat64(key string) float64 { + if value, exists := o.options[key]; exists { + if v, ok := value.(float64); ok { + return v + } + } + return 0 +} + // Get an option value as a duration func (o *Opts) GetDuration(key string) time.Duration { if value, exists := o.options[key]; exists { @@ -124,7 +150,7 @@ func WithToolKit(toolkit ToolKit) Opt { } // Set chat streaming function -func WithStream(fn func(Context)) Opt { +func WithStream(fn func(ContextContent)) Opt { return func(o *Opts) error { o.callback = fn return nil @@ -196,3 +222,11 @@ func WithTopK(v uint) Opt { return nil } } + +// Set system prompt +func WithSystemPrompt(v string) Opt { + return func(o *Opts) error { + o.system = v + return nil + } +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index c91aa4b..312c327 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -56,7 +56,7 @@ func (m model) String() string { // PUBLIC METHODS // Return a list of tool names -func (a *Agent) Tools() []string { +func (a *Agent) ToolNames() []string { if a.ToolKit() == nil { return nil } @@ -67,11 +67,35 @@ func (a *Agent) Tools() []string { return result } +// Return a list of agent names +func (a *Agent) AgentNames() []string { + var result []string + for _, a := range a.Agents() { + result = append(result, a.Name()) + } + return result +} + +// Return a list of agents +func (a *Agent) AgentsWithName(name ...string) []llm.Agent { + all := a.Agents() + if len(name) == 0 { + return all + } + result := make([]llm.Agent, 0, len(name)) + for _, a := range all { + if slices.Contains(name, a.Name()) { + result = append(result, a) + } + } + return result +} + // Return a comma-separated list of agent names func (a *Agent) Name() string { var keys []string - for key := range a.Agents() { - keys = append(keys, key) + for _, agent := range a.Agents() { + keys = append(keys, agent.Name()) } return strings.Join(keys, ",") } @@ -82,22 +106,13 @@ func (a *Agent) Models(ctx context.Context) ([]llm.Model, error) { } // Return the models from list of agents -func (a *Agent) ListModels(ctx context.Context, agents ...string) ([]llm.Model, error) { +func (a *Agent) ListModels(ctx context.Context, names ...string) ([]llm.Model, error) { var result error - // Ensure all agents are valid + // Gather models from agents + agents := a.AgentsWithName(names...) + models := make([]llm.Model, 0, len(agents)*10) for _, agent := range agents { - if _, exists := a.agents[agent]; !exists { - result = errors.Join(result, llm.ErrNotFound.Withf("agent %q", agent)) - } - } - - // Gather models from all agents - models := make([]llm.Model, 0, 100) - for _, agent := range a.agents { - if len(agents) > 0 && !slices.Contains(agents, agent.Name()) { - continue - } agentmodels, err := modelsForAgent(ctx, agent) if err != nil { result = errors.Join(result, err) @@ -113,24 +128,12 @@ func (a *Agent) ListModels(ctx context.Context, agents ...string) ([]llm.Model, // Return a model by name. If no agents are specified, then all agents are considered. // If multiple agents are specified, then the first model found is returned. -func (a *Agent) GetModel(ctx context.Context, name string, agents ...string) (llm.Model, error) { - if len(agents) == 0 { - for _, agent := range a.agents { - agents = append(agents, agent.Name()) - } - } - - // Ensure all agents are valid +func (a *Agent) GetModel(ctx context.Context, name string, agentnames ...string) (llm.Model, error) { var result error - for _, agent := range agents { - if _, exists := a.agents[agent]; !exists { - result = errors.Join(result, llm.ErrNotFound.Withf("agent %q", agent)) - } - } - // Gather models from agents + agents := a.AgentsWithName(agentnames...) for _, agent := range agents { - models, err := modelsForAgent(ctx, a.agents[agent], name) + models, err := modelsForAgent(ctx, agent, name) if err != nil { result = errors.Join(result, err) continue diff --git a/pkg/agent/opt.go b/pkg/agent/opt.go index f7b3d1f..a316881 100644 --- a/pkg/agent/opt.go +++ b/pkg/agent/opt.go @@ -1,7 +1,6 @@ package agent import ( - // Packages client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" @@ -13,23 +12,23 @@ import ( // PUBLIC METHODS func WithOllama(endpoint string, opts ...client.ClientOpt) llm.Opt { - return func(o any) error { + return func(o *llm.Opts) error { client, err := ollama.New(endpoint, opts...) if err != nil { return err } else { - return llm.WithAgent(client) + return llm.WithAgent(client)(o) } } } func WithAnthropic(key string, opts ...client.ClientOpt) llm.Opt { - return func(o any) error { + return func(o *llm.Opts) error { client, err := anthropic.New(key, opts...) if err != nil { return err } else { - return llm.WithAgent(client) + return llm.WithAgent(client)(o) } } } diff --git a/pkg/anthropic/client.go b/pkg/anthropic/client.go index 53299bd..6f4a13b 100644 --- a/pkg/anthropic/client.go +++ b/pkg/anthropic/client.go @@ -4,7 +4,6 @@ anthropic implements an API client for anthropic (https://docs.anthropic.com/en/ package anthropic import ( - // Packages client "github.com/mutablelogic/go-client" llm "github.com/mutablelogic/go-llm" diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 191e12b..5ae4900 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -1,11 +1,8 @@ package anthropic import ( - "bytes" "encoding/json" - "io" "net/http" - "os" "strings" // Packages @@ -104,14 +101,9 @@ var ( ) // Read content from an io.Reader -func ReadContent(r io.Reader, ephemeral, citations bool) (*Content, error) { - var data bytes.Buffer - if _, err := io.Copy(&data, r); err != nil { - return nil, err - } - +func attachmentContent(attachment *llm.Attachment, ephemeral, citations bool) (*Content, error) { // Detect mimetype - mimetype := http.DetectContentType(data.Bytes()) + mimetype := http.DetectContentType(attachment.Data()) if strings.HasPrefix(mimetype, "text/") { // Switch to text/plain - TODO: charsets? mimetype = "text/plain" @@ -124,56 +116,44 @@ func ReadContent(r io.Reader, ephemeral, citations bool) (*Content, error) { } // Create attachment - attachment := new(Content) - attachment.Type = typ + content := new(Content) + content.Type = typ if ephemeral { - attachment.CacheControl = &cachecontrol{Type: "ephemeral"} + content.CacheControl = &cachecontrol{Type: "ephemeral"} } // Handle by type switch typ { case "text": - attachment.Type = "document" - attachment.Source = &contentsource{ + content.Type = "document" + content.Title = attachment.Filename() + content.Source = &contentsource{ Type: "text", MediaType: mimetype, - Data: data.String(), + Data: string(attachment.Data()), } - - // Check for filename - if f, ok := r.(*os.File); ok && f.Name() != "" { - attachment.Title = f.Name() - } - - // Check for citations if citations { - attachment.Citations = &contentcitation{Enabled: true} + content.Citations = &contentcitation{Enabled: true} } case "document": - // Check for filename - if f, ok := r.(*os.File); ok && f.Name() != "" { - attachment.Title = f.Name() - } - - // Check for citations - if citations { - attachment.Citations = &contentcitation{Enabled: true} - } - attachment.Source = &contentsource{ + content.Source = &contentsource{ Type: "base64", MediaType: mimetype, - Data: data.Bytes(), + Data: attachment.Data(), + } + if citations { + content.Citations = &contentcitation{Enabled: true} } case "image": - attachment.Source = &contentsource{ + content.Source = &contentsource{ Type: "base64", MediaType: mimetype, - Data: data.Bytes(), + Data: attachment.Data(), } default: return nil, llm.ErrBadParameter.Withf("unsupported attachment type %q", typ) } // Return success - return attachment, nil + return content, nil } diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index ef92b60..5f44d69 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" // Packages client "github.com/mutablelogic/go-client" @@ -44,42 +43,43 @@ func (r Response) String() string { return string(data) } -func (r opt) String() string { - data, err := json.MarshalIndent(r, "", " ") - if err != nil { - return err.Error() - } - return string(data) -} - /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS type reqMessages struct { - Model string `json:"model"` - Messages []*MessageMeta `json:"messages"` - Tools []llm.Tool `json:"tools,omitempty"` - opt + Model string `json:"model"` + Messages []*MessageMeta `json:"messages"` + Tools []llm.Tool `json:"tools,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Metadata *optmetadata `json:"metadata,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + TopK uint64 `json:"top_k,omitempty"` + TopP float64 `json:"top_p,omitempty"` } func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts ...llm.Opt) (*Response, error) { // Apply options - opt, err := apply(opts...) + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } - // Set max_tokens - if opt.MaxTokens == 0 { - opt.MaxTokens = defaultMaxTokens(context.(*session).model.Name()) - } - // Request req, err := client.NewJSONRequest(reqMessages{ - Model: context.(*session).model.Name(), - Messages: context.(*session).seq, - Tools: opt.tools(anthropic), - opt: *opt, + Model: context.(*session).model.Name(), + Messages: context.(*session).seq, + Tools: optTools(anthropic, opt), + MaxTokens: optMaxTokens(context.(*session).model, opt), + Metadata: optMetadata(opt), + StopSequences: optStopSequences(opt), + Stream: optStream(opt), + System: optSystemPrompt(opt), + Temperature: optTemperature(opt), + TopK: optTopK(opt), + TopP: optTopP(opt), }) if err != nil { return nil, err @@ -90,7 +90,7 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts reqopts := []client.RequestOpt{ client.OptPath("messages"), } - if opt.Stream { + if optStream(opt) { // Append delta to content appendDelta := func(content []*Content, delta *Content) ([]*Content, error) { if len(content) == 0 { @@ -206,8 +206,8 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts return nil } - if opt.callback != nil { - opt.callback(&response) + if fn := opt.StreamFn(); fn != nil { + fn(&response) } // Return success @@ -225,16 +225,20 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts } /////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - -func defaultMaxTokens(model string) uint { - // https://docs.anthropic.com/en/docs/about-claude/models - switch { - case strings.Contains(model, "claude-3-5-haiku"): - return 8192 - case strings.Contains(model, "claude-3-5-sonnet"): - return 8192 - default: - return 4096 +// INTERFACE - CONTEXT CONTENT + +func (response Response) Role() string { + return response.MessageMeta.Role +} + +func (response Response) Text() string { + data, err := json.MarshalIndent(response.MessageMeta.Content, "", " ") + if err != nil { + return err.Error() } + return string(data) +} + +func (response Response) ToolCalls() []llm.ToolCall { + return nil } diff --git a/pkg/anthropic/messages_test.go b/pkg/anthropic/messages_test.go index 270412f..41e9152 100644 --- a/pkg/anthropic/messages_test.go +++ b/pkg/anthropic/messages_test.go @@ -9,8 +9,9 @@ import ( // Packages opts "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" - "github.com/mutablelogic/go-llm/pkg/tool" + tool "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -36,7 +37,7 @@ func Test_messages_001(t *testing.T) { } defer f.Close() - response, err := client.Messages(context.TODO(), model.UserPrompt("what is this image?", anthropic.WithData(f, false, false))) + response, err := client.Messages(context.TODO(), model.UserPrompt("what is this image?", llm.WithAttachment(f))) if assert.NoError(err) { t.Log(response) } @@ -64,7 +65,7 @@ func Test_messages_002(t *testing.T) { } defer f.Close() - response, err := client.Messages(context.TODO(), model.UserPrompt("summarize this document for me", anthropic.WithData(f, false, false))) + response, err := client.Messages(context.TODO(), model.UserPrompt("summarize this document for me", llm.WithAttachment(f))) if assert.NoError(err) { t.Log(response) } @@ -86,7 +87,7 @@ func Test_messages_003(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), llm.WithStream(func(r llm.ContextContent) { t.Log(r) })) if assert.NoError(err) { @@ -115,7 +116,7 @@ func Test_messages_004(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithToolKit(toolkit)) + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), llm.WithToolKit(toolkit)) if assert.NoError(err) { t.Log(response) } @@ -142,9 +143,9 @@ func Test_messages_005(t *testing.T) { t.FailNow() } - response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), anthropic.WithStream(func(r *anthropic.Response) { + response, err := client.Messages(context.TODO(), model.UserPrompt("why is the sky blue"), llm.WithStream(func(r llm.ContextContent) { t.Log(r) - }), anthropic.WithToolKit(toolkit)) + }), llm.WithToolKit(toolkit)) if assert.NoError(err) { t.Log(response) } diff --git a/pkg/anthropic/opt.go b/pkg/anthropic/opt.go index 4e624cf..3f64a42 100644 --- a/pkg/anthropic/opt.go +++ b/pkg/anthropic/opt.go @@ -1,31 +1,15 @@ package anthropic import ( - "io" + "strings" // Packages llm "github.com/mutablelogic/go-llm" - tool "github.com/mutablelogic/go-llm/pkg/tool" ) //////////////////////////////////////////////////////////////////////////////// // TYPES -type opt struct { - MaxTokens uint `json:"max_tokens,omitempty"` - Metadata *optmetadata `json:"metadata,omitempty"` - StopSequences []string `json:"stop_sequences,omitempty"` - Stream bool `json:"stream,omitempty"` - System string `json:"system,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopK uint `json:"top_k,omitempty"` - TopP float64 `json:"top_p,omitempty"` - - data []*Content // Additional message content - callback func(*Response) // Streaming callback - toolkit *tool.ToolKit // Toolkit for tools -} - type optmetadata struct { User string `json:"user_id,omitempty"` } @@ -33,43 +17,104 @@ type optmetadata struct { //////////////////////////////////////////////////////////////////////////////// // OPTIONS -// Messages: Attach data to the request, which can be cached on the server-side -// and cited the response. -func WithAttachment(r io.Reader, ephemeral, citations bool) llm.Opt { - return func(o *llm.Opt) error { - attachment, err := ReadContent(r, ephemeral, citations) - if err != nil { - return err - } - o.(*opt).data = append(o.(*opt).data, attachment) +func WithMaxTokens(v uint) llm.Opt { + return func(o *llm.Opts) error { + o.Set("max_tokens", v) return nil } } -func WithSystem(v string) llm.Opt { - return func(o any) error { - o.(*opt).System = v +func WithUser(v string) llm.Opt { + return func(o *llm.Opts) error { + o.Set("user", v) return nil } } -func WithMaxTokens(v uint) llm.Opt { - return func(o any) error { - o.(*opt).MaxTokens = v +func WithStopSequences(v ...string) llm.Opt { + return func(o *llm.Opts) error { + o.Set("stop", v) return nil } } -func WithUser(v string) llm.Opt { - return func(o any) error { - o.(*opt).Metadata = &optmetadata{User: v} +func WithEphemeral() llm.Opt { + return func(o *llm.Opts) error { + o.Set("ephemeral", true) return nil } } -func WithStopSequences(v ...string) llm.Opt { - return func(o any) error { - o.(*opt).StopSequences = v +func WithCitations() llm.Opt { + return func(o *llm.Opts) error { + o.Set("citations", true) return nil } } + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + +func optCitations(opt *llm.Opts) bool { + return opt.GetBool("citations") +} + +func optEphemeral(opt *llm.Opts) bool { + return opt.GetBool("ephemeral") +} + +func optTools(agent *Client, opts *llm.Opts) []llm.Tool { + toolkit := opts.ToolKit() + if toolkit == nil { + return nil + } + return toolkit.Tools(agent) +} + +func optMaxTokens(model llm.Model, opt *llm.Opts) uint { + // https://docs.anthropic.com/en/docs/about-claude/models + switch { + case strings.Contains(model.Name(), "claude-3-5-haiku"): + return 8192 + case strings.Contains(model.Name(), "claude-3-5-sonnet"): + return 8192 + default: + return 4096 + } +} + +func optMetadata(opt *llm.Opts) *optmetadata { + if user, ok := opt.Get("user").(string); ok { + return &optmetadata{User: user} + } + return nil +} + +func optStopSequences(opt *llm.Opts) []string { + if opt.Has("stop") { + if stop, ok := opt.Get("stop").([]string); ok { + return stop + } + } + return nil +} + +func optStream(opt *llm.Opts) bool { + return opt.StreamFn() != nil +} + +func optSystemPrompt(opt *llm.Opts) string { + return opt.SystemPrompt() +} + +func optTemperature(opt *llm.Opts) float64 { + return opt.GetFloat64("temperature") +} + +func optTopK(opt *llm.Opts) uint64 { + return opt.GetUint64("top_k") +} + +func optTopP(opt *llm.Opts) float64 { + return opt.GetFloat64("top_p") +} diff --git a/pkg/anthropic/session.go b/pkg/anthropic/session.go index c5519a6..4833d3b 100644 --- a/pkg/anthropic/session.go +++ b/pkg/anthropic/session.go @@ -150,22 +150,30 @@ func (session *session) FromTool(context.Context, string, any, ...llm.Opt) error func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { // Apply attachments - opt, err := apply(opts...) + opt, err := llm.ApplyOpts(opts...) if err != nil { return nil, err } + // Get attachments + attachments := opt.Attachments() + + // Create user message meta := MessageMeta{ Role: "user", - Content: make([]*Content, 1, len(opt.data)+1), + Content: make([]*Content, 1, len(attachments)+1), } // Append the text meta.Content[0] = NewTextContent(prompt) // Append any additional data - for _, data := range opt.data { - meta.Content = append(meta.Content, data) + for _, attachment := range attachments { + content, err := attachmentContent(attachment, optEphemeral(opt), optCitations(opt)) + if err != nil { + return nil, err + } + meta.Content = append(meta.Content, content) } // Return success diff --git a/pkg/anthropic/session_test.go b/pkg/anthropic/session_test.go index fe0c094..78c01de 100644 --- a/pkg/anthropic/session_test.go +++ b/pkg/anthropic/session_test.go @@ -7,8 +7,9 @@ import ( // Packages opts "github.com/mutablelogic/go-client" + llm "github.com/mutablelogic/go-llm" anthropic "github.com/mutablelogic/go-llm/pkg/anthropic" - "github.com/mutablelogic/go-llm/pkg/tool" + tool "github.com/mutablelogic/go-llm/pkg/tool" assert "github.com/stretchr/testify/assert" ) @@ -26,7 +27,7 @@ func Test_session_001(t *testing.T) { // Session with a single user prompt - streaming t.Run("stream", func(t *testing.T) { assert := assert.New(t) - session := model.Context(anthropic.WithStream(func(stream *anthropic.Response) { + session := model.Context(llm.WithStream(func(stream llm.ContextContent) { t.Log("SESSION DELTA", stream) })) assert.NotNil(session) @@ -74,7 +75,7 @@ func Test_session_002(t *testing.T) { t.FailNow() } - session := model.Context(anthropic.WithToolKit(toolkit)) + session := model.Context(llm.WithToolKit(toolkit)) assert.NotNil(session) err = session.FromUser(context.TODO(), "What is today's weather, in Berlin?") @@ -82,7 +83,7 @@ func Test_session_002(t *testing.T) { t.FailNow() } - err := toolkit.Run(context.TODO(), session.ToolCalls()) + err := toolkit.Run(context.TODO(), session.ToolCalls()...) if !assert.NoError(err) { t.FailNow() } diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index 8bbfeaa..e0947e5 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -54,6 +54,7 @@ type reqChat struct { Format string `json:"format,omitempty"` Options map[string]interface{} `json:"options,omitempty"` Stream bool `json:"stream"` + System string `json:"system,omitempty"` KeepAlive *time.Duration `json:"keep_alive,omitempty"` } @@ -70,7 +71,8 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. Tools: optTools(ollama, opt), Format: optFormat(opt), Options: optOptions(opt), - Stream: optStream(opt), + Stream: optStream(ollama, opt), + System: optSystemPrompt(opt), KeepAlive: optKeepAlive(opt), }) if err != nil { @@ -104,13 +106,16 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. } // We return the delta or the response - if optStream(opt) { + if optStream(ollama, opt) { return &response, nil } else { return &delta, nil } } +/////////////////////////////////////////////////////////////////////////////// +// INTERFACE - CONTEXT CONTENT + func (response Response) Role() string { return response.Message.Role } @@ -119,14 +124,6 @@ func (response Response) Text() string { return response.Message.Content } -func (response Response) ToolCalls() []ToolCall { - return response.Message.ToolCalls -} - -func (response Response) FromUser(context.Context, string, ...llm.Opt) error { - return llm.ErrNotImplemented -} - -func (response Response) FromTool(context.Context, string, any, ...llm.Opt) error { - return llm.ErrNotImplemented +func (response Response) ToolCalls() []llm.ToolCall { + return nil } diff --git a/pkg/ollama/opt.go b/pkg/ollama/opt.go index 7f7a1e1..f5a28d0 100644 --- a/pkg/ollama/opt.go +++ b/pkg/ollama/opt.go @@ -79,6 +79,10 @@ func optPullStatus(opts *llm.Opts) func(*PullStatus) { return nil } +func optSystemPrompt(opts *llm.Opts) string { + return opts.SystemPrompt() +} + func optTools(agent *Client, opts *llm.Opts) []ToolFunction { toolkit := opts.ToolKit() if toolkit == nil { @@ -122,7 +126,14 @@ func optOptions(opts *llm.Opts) map[string]any { return result } -func optStream(opts *llm.Opts) bool { +func optStream(agent *Client, opts *llm.Opts) bool { + // Streaming only if there is a stream function and no tools + toolkit := opts.ToolKit() + if toolkit != nil { + if tools := toolkit.Tools(agent); len(tools) > 0 { + return false + } + } return opts.StreamFn() != nil } diff --git a/pkg/tool/call.go b/pkg/tool/call.go index 74041f7..90d584f 100644 --- a/pkg/tool/call.go +++ b/pkg/tool/call.go @@ -35,7 +35,7 @@ func NewCall(id, name string, input map[string]any) *call { /////////////////////////////////////////////////////////////////////////////// // STRINGIFY -func (t *call) String() string { +func (t call) String() string { data, err := json.MarshalIndent(t.meta, "", " ") if err != nil { return err.Error() @@ -46,15 +46,15 @@ func (t *call) String() string { /////////////////////////////////////////////////////////////////////////////// // PUBLIC METHODS -func (t *call) Name() string { +func (t call) Name() string { return t.meta.Name } -func (t *call) Id() string { +func (t call) Id() string { return t.meta.Id } -func (t *call) Decode(v any) error { +func (t call) Decode(v any) error { if data, err := json.Marshal(t.meta.Input); err != nil { return err } else { diff --git a/pkg/tool/tool.go_old b/pkg/tool/old/tool.go_old similarity index 100% rename from pkg/tool/tool.go_old rename to pkg/tool/old/tool.go_old diff --git a/pkg/anthropic/tool.go_old b/pkg/tool/old/tool.go_old_old similarity index 100% rename from pkg/anthropic/tool.go_old rename to pkg/tool/old/tool.go_old_old diff --git a/pkg/tool/tool_test.go_old b/pkg/tool/old/tool_test.go_old similarity index 100% rename from pkg/tool/tool_test.go_old rename to pkg/tool/old/tool_test.go_old diff --git a/pkg/tool/result.go b/pkg/tool/result.go new file mode 100644 index 0000000..deba17f --- /dev/null +++ b/pkg/tool/result.go @@ -0,0 +1,46 @@ +package tool + +import ( + // Packages + "encoding/json" + + llm "github.com/mutablelogic/go-llm" +) + +/////////////////////////////////////////////////////////////////////////////// +// TYPES + +type result struct { + call llm.ToolCall + value any +} + +var _ llm.ToolResult = (*result)(nil) + +/////////////////////////////////////////////////////////////////////////////// +// STRINGIFY + +func (r result) String() string { + type j struct { + Call llm.ToolCall `json:"call"` + Result any `json:"result"` + } + data, err := json.MarshalIndent(j{r.call, r.value}, "", " ") + if err != nil { + return err.Error() + } + return string(data) +} + +/////////////////////////////////////////////////////////////////////////////// +// PUBLIC METHODS + +// The call associated with the result +func (r result) Call() llm.ToolCall { + return r.call +} + +// The result, which can be encoded into json +func (r result) Result() any { + return r.value +} diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go index f684741..579ea26 100644 --- a/pkg/tool/toolkit.go +++ b/pkg/tool/toolkit.go @@ -3,7 +3,6 @@ package tool import ( "context" "errors" - "fmt" "sync" // Packages @@ -105,9 +104,10 @@ func (kit *ToolKit) Register(v llm.Tool) error { } // Run calls a tool in the toolkit -func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) error { +func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) ([]llm.ToolResult, error) { var wg sync.WaitGroup - var result error + var errs error + var toolresult []llm.ToolResult // TODO: Lock each tool so it can only be run in series (although different // tools can be run in parallel) @@ -116,19 +116,16 @@ func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) error { go func(call llm.ToolCall) { defer wg.Done() - fmt.Println("Running ", call) - // Get the tool and run it name := call.Name() if _, exists := kit.functions[name]; !exists { - result = errors.Join(result, llm.ErrNotFound.Withf("tool %q not found", name)) + errs = errors.Join(errs, llm.ErrNotFound.Withf("tool %q not found", name)) } else if err := call.Decode(kit.functions[name].Tool); err != nil { - result = errors.Join(result, err) + errs = errors.Join(errs, err) } else if out, err := kit.functions[name].Tool.Run(ctx); err != nil { - result = errors.Join(result, err) + errs = errors.Join(errs, err) } else { - // TODO: Return the result alongside the call - fmt.Println("result of calling", call, "is", out) + toolresult = append(toolresult, &result{call, out}) } }(call) } @@ -137,5 +134,5 @@ func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) error { wg.Wait() // Return any errors - return result + return toolresult, errs } diff --git a/toolkit.go b/toolkit.go index 2fd49e0..c461bc3 100644 --- a/toolkit.go +++ b/toolkit.go @@ -15,9 +15,8 @@ type ToolKit interface { // Return all the tools Tools(Agent) []Tool - // Run the tool calls in parallel - // TODO: Return tool results - Run(context.Context, ...ToolCall) error + // Run the tool calls in parallel and return the results + Run(context.Context, ...ToolCall) ([]ToolResult, error) } // Definition of a tool @@ -44,3 +43,12 @@ type ToolCall interface { // Decode the calling parameters Decode(v any) error } + +// Results from calling tools +type ToolResult interface { + // The call associated with the result + Call() ToolCall + + // The result, which can be encoded into json + Result() any +} From 116185ac8090bbcef7ca1de38b3fc6bbbfd75f9d Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 11:59:13 +0100 Subject: [PATCH 30/33] Updated llm --- cmd/agent/chat.go | 4 +++- context.go | 8 ++++---- pkg/anthropic/message.go | 22 ++++++++++++++++++++-- pkg/anthropic/session.go | 40 ++++++++++++++++++++++++++++++++++++++-- pkg/ollama/session.go | 27 +++++++++++++-------------- pkg/tool/call.go | 4 ++++ pkg/tool/result.go | 36 ++++++++++++++++++++++++++---------- pkg/tool/toolkit.go | 2 +- toolkit.go | 2 +- 9 files changed, 110 insertions(+), 35 deletions(-) diff --git a/cmd/agent/chat.go b/cmd/agent/chat.go index 775f2dd..85ff9d0 100644 --- a/cmd/agent/chat.go +++ b/cmd/agent/chat.go @@ -79,8 +79,10 @@ func (cmd *ChatCmd) Run(globals *Globals) error { calls := session.ToolCalls() if results, err := globals.toolkit.Run(ctx, calls...); err != nil { return err + } else if err := session.FromTool(ctx, results...); err != nil { + return err } else { - fmt.Println(results) + fmt.Println(session.Text()) } } }) diff --git a/context.go b/context.go index 889a7ba..cc8e83c 100644 --- a/context.go +++ b/context.go @@ -22,10 +22,10 @@ type Context interface { ContextContent // Generate a response from a user prompt (with attachments and - // other empheral options + // other options) FromUser(context.Context, string, ...Opt) error - // Generate a response from a tool, passing the call identifier or - // function name, and the result - FromTool(context.Context, string, any, ...Opt) error + // Generate a response from a tool, passing the results + // from the tool call + FromTool(context.Context, ...ToolResult) error } diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 5ae4900..6c54039 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -46,8 +46,8 @@ type ContentAttachment struct { } type ContentToolResult struct { - Id string `json:"tool_use_id,omitempty"` // tool id - Content []*Content `json:"content,omitempty"` + Id string `json:"tool_use_id,omitempty"` // tool id + Content any `json:"content,omitempty"` } type contentsource struct { @@ -75,6 +75,24 @@ func NewTextContent(v string) *Content { return content } +// Return a Content object with tool result +func NewToolResultContent(v llm.ToolResult) *Content { + content := new(Content) + content.Type = "tool_result" + content.ContentToolResult.Id = v.Call().Id() + // content.ContentToolResult.Name = v.Call().Name() + + // We only support JSON encoding for the moment + data, err := json.Marshal(v.Value()) + if err != nil { + content.ContentToolResult.Content = err.Error() + } else { + content.ContentToolResult.Content = string(data) + } + + return content +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY diff --git a/pkg/anthropic/session.go b/pkg/anthropic/session.go index 4833d3b..63c4a7f 100644 --- a/pkg/anthropic/session.go +++ b/pkg/anthropic/session.go @@ -141,8 +141,25 @@ func (session *session) FromUser(ctx context.Context, prompt string, opts ...llm // Generate a response from a tool, passing the call identifier or // function name, and the result -func (session *session) FromTool(context.Context, string, any, ...llm.Opt) error { - return llm.ErrNotImplemented +func (session *session) FromTool(ctx context.Context, results ...llm.ToolResult) error { + meta, err := toolResults(results...) + if err != nil { + return err + } else { + session.seq = append(session.seq, meta) + } + + // Call the 'chat' method + client := session.model.client + r, err := client.Messages(ctx, session, session.opts...) + if err != nil { + return err + } else { + session.seq = append(session.seq, &r.MessageMeta) + } + + // Return success + return nil } /////////////////////////////////////////////////////////////////////////////// @@ -179,3 +196,22 @@ func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { // Return success return &meta, nil } + +func toolResults(results ...llm.ToolResult) (*MessageMeta, error) { + // Check for no results + if len(results) == 0 { + return nil, llm.ErrBadParameter.Withf("No tool results") + } + + // Create user message + meta := MessageMeta{ + Role: "user", + Content: make([]*Content, 0, len(results)), + } + for _, result := range results { + meta.Content = append(meta.Content, NewToolResultContent(result)) + } + + // Return success + return &meta, nil +} diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 78d0d1d..0069786 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -91,21 +91,20 @@ func (s *session) FromUser(ctx context.Context, prompt string, opts ...llm.Opt) } // Generate a response from a tool calling result -func (s *session) FromTool(ctx context.Context, call string, result any, opts ...llm.Opt) error { - // Append the tool result - if message, err := toolResult(call, result); err != nil { - return err - } else { - s.seq[len(s.seq)-1] = message +func (s *session) FromTool(ctx context.Context, results ...llm.ToolResult) error { + if len(results) == 0 { + return llm.ErrConflict.Withf("No tool results") } - // The options come from the session options and the user options - chatopts := make([]llm.Opt, 0, len(s.opts)+len(opts)) - chatopts = append(chatopts, s.opts...) - chatopts = append(chatopts, opts...) + // Append the tool results + for _, result := range results { + if message, err := toolResult(result); err != nil { + s.seq = append(s.seq, message) + } + } // Call the 'chat' method - r, err := s.model.client.Chat(ctx, s, chatopts...) + r, err := s.model.client.Chat(ctx, s, s.opts...) if err != nil { return err } else { @@ -177,9 +176,9 @@ func userPrompt(prompt string, opts ...llm.Opt) (*MessageMeta, error) { return &meta, nil } -func toolResult(name string, result any) (*MessageMeta, error) { +func toolResult(result llm.ToolResult) (*MessageMeta, error) { // Turn result into JSON - data, err := json.Marshal(result) + data, err := json.Marshal(result.Value()) if err != nil { return nil, err } @@ -187,7 +186,7 @@ func toolResult(name string, result any) (*MessageMeta, error) { // Create a new message var meta MessageMeta meta.Role = "tool" - meta.FunctionName = name + meta.FunctionName = result.Call().Name() meta.Content = string(data) // Return success diff --git a/pkg/tool/call.go b/pkg/tool/call.go index 90d584f..47cfdf0 100644 --- a/pkg/tool/call.go +++ b/pkg/tool/call.go @@ -35,6 +35,10 @@ func NewCall(id, name string, input map[string]any) *call { /////////////////////////////////////////////////////////////////////////////// // STRINGIFY +func (t call) MarshalJSON() ([]byte, error) { + return json.Marshal(t.meta) +} + func (t call) String() string { data, err := json.MarshalIndent(t.meta, "", " ") if err != nil { diff --git a/pkg/tool/result.go b/pkg/tool/result.go index deba17f..d3ba19e 100644 --- a/pkg/tool/result.go +++ b/pkg/tool/result.go @@ -10,22 +10,38 @@ import ( /////////////////////////////////////////////////////////////////////////////// // TYPES +type ResultMeta struct { + Call llm.ToolCall `json:"call"` + Value any `json:"result"` +} + type result struct { - call llm.ToolCall - value any + meta ResultMeta } var _ llm.ToolResult = (*result)(nil) +/////////////////////////////////////////////////////////////////////////////// +// LIFECYCLE + +func NewResult(call llm.ToolCall, value any) llm.ToolResult { + return &result{ + meta: ResultMeta{ + Call: call, + Value: value, + }, + } +} + /////////////////////////////////////////////////////////////////////////////// // STRINGIFY +func (r result) MarshalJSON() ([]byte, error) { + return json.Marshal(r.meta) +} + func (r result) String() string { - type j struct { - Call llm.ToolCall `json:"call"` - Result any `json:"result"` - } - data, err := json.MarshalIndent(j{r.call, r.value}, "", " ") + data, err := json.MarshalIndent(r.meta, "", " ") if err != nil { return err.Error() } @@ -37,10 +53,10 @@ func (r result) String() string { // The call associated with the result func (r result) Call() llm.ToolCall { - return r.call + return r.meta.Call } // The result, which can be encoded into json -func (r result) Result() any { - return r.value +func (r result) Value() any { + return r.meta.Value } diff --git a/pkg/tool/toolkit.go b/pkg/tool/toolkit.go index 579ea26..6d9fd34 100644 --- a/pkg/tool/toolkit.go +++ b/pkg/tool/toolkit.go @@ -125,7 +125,7 @@ func (kit *ToolKit) Run(ctx context.Context, calls ...llm.ToolCall) ([]llm.ToolR } else if out, err := kit.functions[name].Tool.Run(ctx); err != nil { errs = errors.Join(errs, err) } else { - toolresult = append(toolresult, &result{call, out}) + toolresult = append(toolresult, NewResult(call, out)) } }(call) } diff --git a/toolkit.go b/toolkit.go index c461bc3..e61d98d 100644 --- a/toolkit.go +++ b/toolkit.go @@ -50,5 +50,5 @@ type ToolResult interface { Call() ToolCall // The result, which can be encoded into json - Result() any + Value() any } From db433cdbcae1c0150b526b3169b695188e48720c Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 13:14:07 +0100 Subject: [PATCH 31/33] Updated llm --- cmd/agent/chat.go | 35 ++++++++++++++------ pkg/anthropic/messages.go | 1 - pkg/newsapi/agent.go | 70 +++++++++++++++++++++++++++++++++------ pkg/newsapi/client.go | 8 ++++- pkg/ollama/chat.go | 19 ++++++++--- pkg/ollama/session.go | 2 ++ 6 files changed, 107 insertions(+), 28 deletions(-) diff --git a/cmd/agent/chat.go b/cmd/agent/chat.go index 85ff9d0..ffa29b4 100644 --- a/cmd/agent/chat.go +++ b/cmd/agent/chat.go @@ -40,7 +40,7 @@ func (cmd *ChatCmd) Run(globals *Globals) error { opts := []llm.Opt{} if !cmd.NoStream { opts = append(opts, llm.WithStream(func(cc llm.ContextContent) { - fmt.Println(cc) + fmt.Println("STREAM", cc) })) } if cmd.System != "" { @@ -73,17 +73,30 @@ func (cmd *ChatCmd) Run(globals *Globals) error { return err } - fmt.Println(session.Text()) - - // If there are tool calls, then do these - calls := session.ToolCalls() - if results, err := globals.toolkit.Run(ctx, calls...); err != nil { - return err - } else if err := session.FromTool(ctx, results...); err != nil { - return err - } else { - fmt.Println(session.Text()) + // Repeat call tools until no more calls are made + for { + calls := session.ToolCalls() + if len(calls) == 0 { + break + } + if session.Text() != "" { + fmt.Println("Calling", session.Text()) + } else { + var names []string + for _, call := range calls { + names = append(names, call.Name()) + } + fmt.Println("Calling", strings.Join(names, ", ")) + } + if results, err := globals.toolkit.Run(ctx, calls...); err != nil { + return err + } else if err := session.FromTool(ctx, results...); err != nil { + return err + } } + + // Print the response + fmt.Println("SESSION", session.Text()) } }) } diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 5f44d69..3aa34e5 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -150,7 +150,6 @@ func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts if err := evt.Json(&r); err != nil { return err } else if int(r.Index) != len(response.MessageMeta.Content)-1 { - fmt.Println(response.MessageMeta) return fmt.Errorf("%s: unexpected index %d", r.Type, r.Index) } else if content, err := appendDelta(response.MessageMeta.Content, &r.Content); err != nil { return err diff --git a/pkg/newsapi/agent.go b/pkg/newsapi/agent.go index cea7991..96b2631 100644 --- a/pkg/newsapi/agent.go +++ b/pkg/newsapi/agent.go @@ -2,6 +2,8 @@ package newsapi import ( "context" + "fmt" + "slices" // Packages llm "github.com/mutablelogic/go-llm" @@ -11,32 +13,78 @@ import ( // TYPES type headlines struct { - *Client `json:"-"` - CountryCode string `json:"country_code,omitempty" help:"The two-letter countrycode to return headlines for"` + *Client `json:"-"` + // CountryCode string `json:"country_code,omitempty" help:"The two-letter countrycode to return headlines for. Leave empty for worldwide headlines."` +} + +type search struct { + *Client `json:"-"` + Query string `json:"query" help:"A phrase used to search for news headlines." required:"true"` +} + +type category struct { + *Client `json:"-"` + Category string `json:"category" enum:"business, entertainment, health, science, sports, technology" help:"business, entertainment, health, science, sports, technology" required:"true"` } var _ llm.Tool = (*headlines)(nil) +var ( + categories = []string{"business", "entertainment", "health", "science", "sports", "technology"} +) + /////////////////////////////////////////////////////////////////////////////// // HEADLINES func (headlines) Name() string { - return "current_headlines" + return "news_headlines" } func (headlines) Description() string { - return "Return the current news headlines, optionally for a specific country" + return "Return the current global news headlines" } func (headlines *headlines) Run(ctx context.Context) (any, error) { - response, err := headlines.Headlines(OptCategory("general"), OptLimit(5)) - if err != nil { - return nil, err + return headlines.Headlines(OptCategory("general"), OptLimit(10)) +} + +/////////////////////////////////////////////////////////////////////////////// +// SEARCH + +func (search) Name() string { + return "news_search" +} + +func (search) Description() string { + return "Search the news archive with a search query" +} + +func (search *search) Run(ctx context.Context) (any, error) { + if search.Query == "" { + return nil, nil } - return map[string]any{ - "type": "text", - "headlines": response, - }, nil + fmt.Printf("search for %q\n", search.Query) + return search.Articles(OptQuery(search.Query), OptLimit(10)) +} + +/////////////////////////////////////////////////////////////////////////////// +// CATEGORY + +func (category) Name() string { + return "news_headlines_category" +} + +func (category) Description() string { + return "Return the news headlines for a specific category" +} + +func (category *category) Run(ctx context.Context) (any, error) { + if !slices.Contains(categories, category.Category) { + fmt.Printf("search for %q\n", category.Category) + return category.Articles(OptQuery(category.Category), OptLimit(10)) + } + fmt.Printf("category for %q\n", category.Category) + return category.Headlines(OptCategory(category.Category), OptLimit(10)) } /* diff --git a/pkg/newsapi/client.go b/pkg/newsapi/client.go index 1b66d5f..6851e35 100644 --- a/pkg/newsapi/client.go +++ b/pkg/newsapi/client.go @@ -42,7 +42,13 @@ func New(ApiKey string, opts ...client.ClientOpt) (*Client, error) { func (newsapi *Client) RegisterWithToolKit(toolkit *tool.ToolKit) error { // Register tools - if err := toolkit.Register(&headlines{newsapi, ""}); err != nil { + if err := toolkit.Register(&headlines{newsapi}); err != nil { + return err + } + if err := toolkit.Register(&search{newsapi, ""}); err != nil { + return err + } + if err := toolkit.Register(&category{newsapi, ""}); err != nil { return err } diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index e0947e5..0f067a7 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -64,15 +64,24 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. return nil, err } + // Append the system prompt at the beginning + seq := make([]*MessageMeta, 0, len(prompt.(*session).seq)+1) + if system := opt.SystemPrompt(); system != "" { + seq = append(seq, &MessageMeta{ + Role: "system", + Content: opt.SystemPrompt(), + }) + } + seq = append(seq, prompt.(*session).seq...) + // Request req, err := client.NewJSONRequest(reqChat{ Model: prompt.(*session).model.Name(), - Messages: prompt.(*session).seq, + Messages: seq, Tools: optTools(ollama, opt), Format: optFormat(opt), Options: optOptions(opt), Stream: optStream(ollama, opt), - System: optSystemPrompt(opt), KeepAlive: optKeepAlive(opt), }) if err != nil { @@ -97,8 +106,10 @@ func (ollama *Client) Chat(ctx context.Context, prompt llm.Context, opts ...llm. } //Call the chat callback - if fn := opt.StreamFn(); fn != nil { - fn(&response) + if optStream(ollama, opt) { + if fn := opt.StreamFn(); fn != nil { + fn(&response) + } } return nil })); err != nil { diff --git a/pkg/ollama/session.go b/pkg/ollama/session.go index 0069786..867f60a 100644 --- a/pkg/ollama/session.go +++ b/pkg/ollama/session.go @@ -99,6 +99,8 @@ func (s *session) FromTool(ctx context.Context, results ...llm.ToolResult) error // Append the tool results for _, result := range results { if message, err := toolResult(result); err != nil { + return err + } else { s.seq = append(s.seq, message) } } From 4d87df88ccbe0c1da3583596b38d89b40ed29cef Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 15:48:25 +0100 Subject: [PATCH 32/33] Updates for anthropic --- cmd/agent/chat.go | 10 +- cmd/agent/term.go | 17 +++- pkg/anthropic/message.go | 20 +++- pkg/anthropic/messages.go | 10 +- pkg/anthropic/session.go | 6 +- pkg/newsapi/agent.go | 193 +++++--------------------------------- pkg/ollama/chat.go | 1 - pkg/tool/tool.go | 8 +- 8 files changed, 70 insertions(+), 195 deletions(-) diff --git a/cmd/agent/chat.go b/cmd/agent/chat.go index ffa29b4..8331d90 100644 --- a/cmd/agent/chat.go +++ b/cmd/agent/chat.go @@ -40,7 +40,9 @@ func (cmd *ChatCmd) Run(globals *Globals) error { opts := []llm.Opt{} if !cmd.NoStream { opts = append(opts, llm.WithStream(func(cc llm.ContextContent) { - fmt.Println("STREAM", cc) + if text := cc.Text(); text != "" { + fmt.Println(text) + } })) } if cmd.System != "" { @@ -80,13 +82,13 @@ func (cmd *ChatCmd) Run(globals *Globals) error { break } if session.Text() != "" { - fmt.Println("Calling", session.Text()) + globals.term.Println(session.Text()) } else { var names []string for _, call := range calls { names = append(names, call.Name()) } - fmt.Println("Calling", strings.Join(names, ", ")) + globals.term.Println("Calling", strings.Join(names, ", ")) } if results, err := globals.toolkit.Run(ctx, calls...); err != nil { return err @@ -96,7 +98,7 @@ func (cmd *ChatCmd) Run(globals *Globals) error { } // Print the response - fmt.Println("SESSION", session.Text()) + globals.term.Println("\n" + session.Text() + "\n") } }) } diff --git a/cmd/agent/term.go b/cmd/agent/term.go index 9491f1d..346a0e2 100644 --- a/cmd/agent/term.go +++ b/cmd/agent/term.go @@ -1,11 +1,14 @@ package main import ( + "fmt" "io" "os" // Packages - "golang.org/x/term" + format "github.com/MichaelMure/go-term-text" + color "github.com/fatih/color" + term "golang.org/x/term" ) type Term struct { @@ -41,6 +44,15 @@ func (t *Term) Size() (int, int) { return 0, 0 } +func (t *Term) Println(v ...any) { + text := fmt.Sprint(v...) + w, _ := t.Size() + if w > 0 { + text, _ = format.Wrap(text, w) + } + fmt.Fprintln(os.Stdout, text) +} + func (t *Term) ReadLine(prompt string) (string, error) { // Set terminal raw mode if t.Terminal != nil { @@ -51,8 +63,9 @@ func (t *Term) ReadLine(prompt string) (string, error) { defer term.Restore(t.fd, state) } - // Set the prompt + // Set the prompt with color if t.Terminal != nil { + prompt = color.New(color.Bold).Sprint(prompt) t.Terminal.SetPrompt(prompt) } diff --git a/pkg/anthropic/message.go b/pkg/anthropic/message.go index 6c54039..3a8acdd 100644 --- a/pkg/anthropic/message.go +++ b/pkg/anthropic/message.go @@ -22,7 +22,7 @@ type Content struct { Type string `json:"type"` // image, document, text, tool_use ContentText ContentAttachment - ContentTool + *ContentTool ContentToolResult CacheControl *cachecontrol `json:"cache_control,omitempty"` // ephemeral } @@ -34,7 +34,7 @@ type ContentText struct { type ContentTool struct { Id string `json:"id,omitempty"` // tool id Name string `json:"name,omitempty"` // tool name - Input map[string]any `json:"input,omitempty"` // tool input + Input map[string]any `json:"input"` // tool input InputJson string `json:"partial_json,omitempty"` // partial json input (for streaming) } @@ -107,6 +107,22 @@ func (m MessageMeta) String() string { /////////////////////////////////////////////////////////////////////////////// // PRIVATE METHODS +func (m MessageMeta) Text() string { + if len(m.Content) == 0 { + return "" + } + var text []string + for _, content := range m.Content { + if content.Type == "text" { + text = append(text, content.ContentText.Text) + } + } + return strings.Join(text, "\n") +} + +/////////////////////////////////////////////////////////////////////////////// +// PRIVATE METHODS + var ( supportedAttachments = map[string]string{ "image/jpeg": "image", diff --git a/pkg/anthropic/messages.go b/pkg/anthropic/messages.go index 3aa34e5..d8becd3 100644 --- a/pkg/anthropic/messages.go +++ b/pkg/anthropic/messages.go @@ -48,8 +48,6 @@ func (r Response) String() string { type reqMessages struct { Model string `json:"model"` - Messages []*MessageMeta `json:"messages"` - Tools []llm.Tool `json:"tools,omitempty"` MaxTokens uint `json:"max_tokens,omitempty"` Metadata *optmetadata `json:"metadata,omitempty"` StopSequences []string `json:"stop_sequences,omitempty"` @@ -58,6 +56,8 @@ type reqMessages struct { Temperature float64 `json:"temperature,omitempty"` TopK uint64 `json:"top_k,omitempty"` TopP float64 `json:"top_p,omitempty"` + Messages []*MessageMeta `json:"messages"` + Tools []llm.Tool `json:"tools,omitempty"` } func (anthropic *Client) Messages(ctx context.Context, context llm.Context, opts ...llm.Opt) (*Response, error) { @@ -231,11 +231,7 @@ func (response Response) Role() string { } func (response Response) Text() string { - data, err := json.MarshalIndent(response.MessageMeta.Content, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return response.MessageMeta.Text() } func (response Response) ToolCalls() []llm.ToolCall { diff --git a/pkg/anthropic/session.go b/pkg/anthropic/session.go index 63c4a7f..d545582 100644 --- a/pkg/anthropic/session.go +++ b/pkg/anthropic/session.go @@ -82,11 +82,7 @@ func (session *session) Text() string { return "" } meta := session.seq[len(session.seq)-1] - data, err := json.MarshalIndent(meta.Content, "", " ") - if err != nil { - return err.Error() - } - return string(data) + return meta.Text() } // Return the current session tool calls, or empty if no tool calls were made diff --git a/pkg/newsapi/agent.go b/pkg/newsapi/agent.go index 96b2631..ee0f658 100644 --- a/pkg/newsapi/agent.go +++ b/pkg/newsapi/agent.go @@ -10,32 +10,14 @@ import ( ) /////////////////////////////////////////////////////////////////////////////// -// TYPES +// HEADLINES type headlines struct { *Client `json:"-"` - // CountryCode string `json:"country_code,omitempty" help:"The two-letter countrycode to return headlines for. Leave empty for worldwide headlines."` -} - -type search struct { - *Client `json:"-"` - Query string `json:"query" help:"A phrase used to search for news headlines." required:"true"` -} - -type category struct { - *Client `json:"-"` - Category string `json:"category" enum:"business, entertainment, health, science, sports, technology" help:"business, entertainment, health, science, sports, technology" required:"true"` } var _ llm.Tool = (*headlines)(nil) -var ( - categories = []string{"business", "entertainment", "health", "science", "sports", "technology"} -) - -/////////////////////////////////////////////////////////////////////////////// -// HEADLINES - func (headlines) Name() string { return "news_headlines" } @@ -51,6 +33,13 @@ func (headlines *headlines) Run(ctx context.Context) (any, error) { /////////////////////////////////////////////////////////////////////////////// // SEARCH +type search struct { + *Client `json:"-"` + Query string `json:"query" help:"A phrase used to search for news headlines." required:"true"` +} + +var _ llm.Tool = (*search)(nil) + func (search) Name() string { return "news_search" } @@ -63,13 +52,24 @@ func (search *search) Run(ctx context.Context) (any, error) { if search.Query == "" { return nil, nil } - fmt.Printf("search for %q\n", search.Query) + fmt.Printf(" => Search for %q\n", search.Query) return search.Articles(OptQuery(search.Query), OptLimit(10)) } /////////////////////////////////////////////////////////////////////////////// // CATEGORY +type category struct { + *Client `json:"-"` + Category string `json:"category" enum:"business, entertainment, health, science, sports, technology" help:"business, entertainment, health, science, sports, technology" required:"true"` +} + +var _ llm.Tool = (*category)(nil) + +var ( + categories = []string{"business", "entertainment", "health", "science", "sports", "technology"} +) + func (category) Name() string { return "news_headlines_category" } @@ -80,158 +80,9 @@ func (category) Description() string { func (category *category) Run(ctx context.Context) (any, error) { if !slices.Contains(categories, category.Category) { - fmt.Printf("search for %q\n", category.Category) + fmt.Printf(" => Search for %q\n", category.Category) return category.Articles(OptQuery(category.Category), OptLimit(10)) } - fmt.Printf("category for %q\n", category.Category) + fmt.Printf(" => Headlines for %q\n", category.Category) return category.Headlines(OptCategory(category.Category), OptLimit(10)) } - -/* -// Return all the agent tools for the weatherapi -func (c *Client) Tools() []agent.Tool { - return []agent.Tool{ - &tool{ - name: "current_headlines", - description: "Return the current news headlines", - run: c.agentCurrentHeadlines, - }, &tool{ - name: "current_headlines_country", - description: "Return the current news headlines for a country", - run: c.agentCountryHeadlines, - params: []agent.ToolParameter{ - { - Name: "countrycode", - Description: "The two-letter country code to return headlines for", - Required: true, - }, - }, - }, &tool{ - name: "current_headlines_category", - description: "Return the current news headlines for a business, entertainment, health, science, sports or technology", - run: c.agentCategoryHeadlines, - params: []agent.ToolParameter{ - { - Name: "category", - Description: "business, entertainment, health, science, sports, technology", - Required: true, - }, - }, - }, &tool{ - name: "search_news", - description: "Return the news headlines with a search query", - run: c.agentSearchNews, - params: []agent.ToolParameter{ - { - Name: "query", - Description: "A phrase used to search for news headlines", - Required: true, - }, - }, - }, - } -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - TOOL - -func (*tool) Provider() string { - return "newsapi" -} - -func (t *tool) Name() string { - return t.name -} - -func (t *tool) Description() string { - return t.description -} - -func (t *tool) Params() []agent.ToolParameter { - return t.params -} - -func (t *tool) Run(ctx context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { - return t.run(ctx, call) -} - -/////////////////////////////////////////////////////////////////////////////// -// PRIVATE METHODS - TOOL - -// Return the current general headlines -func (c *Client) agentCurrentHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { - response, err := c.Headlines(OptCategory("general"), OptLimit(5)) - if err != nil { - return nil, err - } - return &agent.ToolResult{ - Id: call.Id, - Result: map[string]any{ - "type": "text", - "headlines": response, - }, - }, nil -} - -// Return the headlines for a specific country -func (c *Client) agentCountryHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { - country, err := call.String("countrycode") - if err != nil { - return nil, err - } - country = strings.ToLower(country) - response, err := c.Headlines(OptCountry(country), OptLimit(5)) - if err != nil { - return nil, err - } - return &agent.ToolResult{ - Id: call.Id, - Result: map[string]any{ - "type": "text", - "country": country, - "headlines": response, - }, - }, nil -} - -// Return the headlines for a specific category -func (c *Client) agentCategoryHeadlines(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { - category, err := call.String("category") - if err != nil { - return nil, err - } - category = strings.ToLower(category) - response, err := c.Headlines(OptCategory(category), OptLimit(5)) - if err != nil { - return nil, err - } - return &agent.ToolResult{ - Id: call.Id, - Result: map[string]any{ - "type": "text", - "category": category, - "headlines": response, - }, - }, nil -} - -// Return the headlines for a specific query -func (c *Client) agentSearchNews(_ context.Context, call *agent.ToolCall) (*agent.ToolResult, error) { - query, err := call.String("query") - if err != nil { - return nil, err - } - response, err := c.Articles(OptQuery(query), OptLimit(5)) - if err != nil { - return nil, err - } - return &agent.ToolResult{ - Id: call.Id, - Result: map[string]any{ - "type": "text", - "query": query, - "headlines": response, - }, - }, nil -} -*/ diff --git a/pkg/ollama/chat.go b/pkg/ollama/chat.go index 0f067a7..14bf5f5 100644 --- a/pkg/ollama/chat.go +++ b/pkg/ollama/chat.go @@ -54,7 +54,6 @@ type reqChat struct { Format string `json:"format,omitempty"` Options map[string]interface{} `json:"options,omitempty"` Stream bool `json:"stream"` - System string `json:"system,omitempty"` KeepAlive *time.Duration `json:"keep_alive,omitempty"` } diff --git a/pkg/tool/tool.go b/pkg/tool/tool.go index aa8fdd5..5b22920 100644 --- a/pkg/tool/tool.go +++ b/pkg/tool/tool.go @@ -30,8 +30,8 @@ type ToolMeta struct { type ToolParameters struct { Type string `json:"type,omitempty"` - Required []string `json:"required,omitempty"` - Properties map[string]ToolParameter `json:"properties,omitempty"` + Required []string `json:"required"` + Properties map[string]ToolParameter `json:"properties"` } type ToolParameter struct { @@ -137,7 +137,9 @@ func paramFor(root []int, field reflect.StructField) (ToolParameter, error) { // Enum enum := []string{} if enum_ := field.Tag.Get("enum"); enum_ != "" { - enum = strings.Split(enum_, ",") + for _, e := range strings.Split(enum_, ",") { + enum = append(enum, strings.TrimSpace(e)) + } } // Return success From cd5c7d0d7f0f9f2c7463093968555adeb497afb2 Mon Sep 17 00:00:00 2001 From: David Thorpe Date: Sat, 1 Feb 2025 16:01:32 +0100 Subject: [PATCH 33/33] Added tooling --- .github/workflows/docker.yaml | 80 +++++++++++++++++++++++ Makefile | 118 ++++++++++++++++++++++++++++++++++ cmd/agent/chat.go | 2 +- cmd/agent/main.go | 2 +- etc/docker/Dockerfile | 28 ++++++++ go.mod | 8 ++- go.sum | 22 +++++++ 7 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/docker.yaml create mode 100644 Makefile create mode 100644 etc/docker/Dockerfile diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..412a450 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,80 @@ +name: Create Docker Image +on: + release: + types: + - created + + workflow_dispatch: + +jobs: + build: + name: Build + strategy: + matrix: + arch: [ amd64, arm64 ] + runs-on: + - ${{ matrix.arch == 'amd64' && 'ubuntu-latest' || matrix.arch }} + env: + OS: linux + ARCH: ${{ matrix.arch }} + DOCKER_REPO: ghcr.io/${{ github.repository }} + DOCKER_SOURCE: https://github.com/${{ github.repository }} + outputs: + tag: ${{ steps.build.outputs.tag }} + permissions: + contents: read + packages: write + steps: + - name: Install build tools + run: | + sudo apt -y update + sudo apt -y install build-essential git + git config --global advice.detachedHead false + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Login + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and Push + id: build + run: | + make docker && make docker-push && make docker-version >> "$GITHUB_OUTPUT" + manifest: + name: Manifest + needs: build + strategy: + matrix: + tag: + - ${{ needs.build.outputs.tag }} + - "latest" + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - name: Login + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create + run: | + docker manifest create ghcr.io/${{ github.repository }}:${{ matrix.tag }} \ + --amend ghcr.io/${{ github.repository }}-linux-amd64:${{ needs.build.outputs.tag }} \ + --amend ghcr.io/${{ github.repository }}-linux-arm64:${{ needs.build.outputs.tag }} + - name: Annotate + run: | + docker manifest annotate --arch amd64 --os linux \ + ghcr.io/${{ github.repository }}:${{ matrix.tag }} \ + ghcr.io/${{ github.repository }}-linux-amd64:${{ needs.build.outputs.tag }} + docker manifest annotate --arch arm64 --os linux \ + ghcr.io/${{ github.repository }}:${{ matrix.tag }} \ + ghcr.io/${{ github.repository }}-linux-arm64:${{ needs.build.outputs.tag }} + - name: Push + run: | + docker manifest push ghcr.io/${{ github.repository }}:${{ matrix.tag }} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8922c74 --- /dev/null +++ b/Makefile @@ -0,0 +1,118 @@ +# Executables +GO ?= $(shell which go 2>/dev/null) +DOCKER ?= $(shell which docker 2>/dev/null) + +# Locations +BUILD_DIR ?= build +CMD_DIR := $(wildcard cmd/*) + +# VERBOSE=1 +ifneq ($(VERBOSE),) + VERBOSE_FLAG = -v +else + VERBOSE_FLAG = +endif + +# Set OS and Architecture +ARCH ?= $(shell arch | tr A-Z a-z | sed 's/x86_64/amd64/' | sed 's/i386/amd64/' | sed 's/armv7l/arm/' | sed 's/aarch64/arm64/') +OS ?= $(shell uname | tr A-Z a-z) +VERSION ?= $(shell git describe --tags --always | sed 's/^v//') + +# Set build flags +BUILD_MODULE = $(shell cat go.mod | head -1 | cut -d ' ' -f 2) +BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitSource=${BUILD_MODULE} +BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitTag=$(shell git describe --tags --always) +BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitBranch=$(shell git name-rev HEAD --name-only --always) +BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GitHash=$(shell git rev-parse HEAD) +BUILD_LD_FLAGS += -X $(BUILD_MODULE)/pkg/version.GoBuildTime=$(shell date -u '+%Y-%m-%dT%H:%M:%SZ') +BUILD_FLAGS = -ldflags "-s -w ${BUILD_LD_FLAGS}" + +# Docker +DOCKER_REPO ?= ghcr.io/mutablelogic/go-llm +DOCKER_SOURCE ?= ${BUILD_MODULE} +DOCKER_TAG = ${DOCKER_REPO}-${OS}-${ARCH}:${VERSION} + +############################################################################### +# ALL + +.PHONY: all +all: clean build + +############################################################################### +# BUILD + +# Build the commands in the cmd directory +.PHONY: build +build: tidy $(CMD_DIR) + +$(CMD_DIR): go-dep mkdir + @echo Build command $(notdir $@) GOOS=${OS} GOARCH=${ARCH} + @GOOS=${OS} GOARCH=${ARCH} ${GO} build ${BUILD_FLAGS} -o ${BUILD_DIR}/$(notdir $@) ./$@ + +# Build the docker image +.PHONY: docker +docker: docker-dep + @echo build docker image ${DOCKER_TAG} OS=${OS} ARCH=${ARCH} SOURCE=${DOCKER_SOURCE} VERSION=${VERSION} + @${DOCKER} build \ + --tag ${DOCKER_TAG} \ + --build-arg ARCH=${ARCH} \ + --build-arg OS=${OS} \ + --build-arg SOURCE=${DOCKER_SOURCE} \ + --build-arg VERSION=${VERSION} \ + -f etc/docker/Dockerfile . + +# Push docker container +.PHONY: docker-push +docker-push: docker-dep + @echo push docker image: ${DOCKER_TAG} + @${DOCKER} push ${DOCKER_TAG} + +# Print out the version +.PHONY: docker-version +docker-version: docker-dep + @echo "tag=${VERSION}" + +############################################################################### +# TEST + +.PHONY: test +test: unit-test coverage-test + +.PHONY: unit-test +unit-test: go-dep + @echo Unit Tests + @${GO} test ${VERBOSE_FLAG} ./pkg/... + +.PHONY: coverage-test +coverage-test: go-dep mkdir + @echo Test Coverage + @${GO} test -coverprofile ${BUILD_DIR}/coverprofile.out ./pkg/... + +############################################################################### +# CLEAN + +.PHONY: tidy +tidy: + @echo Running go mod tidy + @${GO} mod tidy + +.PHONY: mkdir +mkdir: + @install -d ${BUILD_DIR} + +.PHONY: clean +clean: + @echo Clean + @rm -fr $(BUILD_DIR) + @${GO} clean + +############################################################################### +# DEPENDENCIES + +.PHONY: go-dep +go-dep: + @test -f "${GO}" && test -x "${GO}" || (echo "Missing go binary" && exit 1) + +.PHONY: docker-dep +docker-dep: + @test -f "${DOCKER}" && test -x "${DOCKER}" || (echo "Missing docker binary" && exit 1) \ No newline at end of file diff --git a/cmd/agent/chat.go b/cmd/agent/chat.go index 8331d90..4067d75 100644 --- a/cmd/agent/chat.go +++ b/cmd/agent/chat.go @@ -88,7 +88,7 @@ func (cmd *ChatCmd) Run(globals *Globals) error { for _, call := range calls { names = append(names, call.Name()) } - globals.term.Println("Calling", strings.Join(names, ", ")) + globals.term.Println("Calling ", strings.Join(names, ", ")) } if results, err := globals.toolkit.Run(ctx, calls...); err != nil { return err diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 3030550..8d0970c 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -71,7 +71,7 @@ func main() { cli := CLI{} cmd := kong.Parse(&cli, kong.Name(execName()), - kong.Description("Agent command line interface"), + kong.Description("LLM agent command line interface"), kong.UsageOnError(), kong.ConfigureHelp(kong.HelpOptions{Compact: true}), kong.Vars{}, diff --git a/etc/docker/Dockerfile b/etc/docker/Dockerfile new file mode 100644 index 0000000..b612cda --- /dev/null +++ b/etc/docker/Dockerfile @@ -0,0 +1,28 @@ +ARG OS +ARG ARCH + +# Run makefile to build all the commands +FROM --platform=${OS}/${ARCH} golang:latest AS builder +ARG OS +ARG ARCH +WORKDIR /usr/src/app +COPY . . + +# Build the server +RUN \ + apt update -y && apt upgrade -y && \ + OS=${OS} ARCH=${ARCH} make build + +# Copy binaries to /usr/local/bin +FROM --platform=${OS}/${ARCH} debian:bookworm-slim +ARG OS +ARG ARCH +ARG SOURCE +COPY --from=builder /usr/src/app/build/* /usr/local/bin/ +RUN apt update -y && apt install -y ca-certificates + +# Labels +LABEL org.opencontainers.image.source=https://${SOURCE} + +# Entrypoint when running the server +ENTRYPOINT [ "/usr/local/bin/agent" ] diff --git a/go.mod b/go.mod index 597559b..199ec9e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/mutablelogic/go-llm go 1.23.5 require ( + github.com/MichaelMure/go-term-text v0.3.1 github.com/alecthomas/kong v1.7.0 + github.com/djthorpe/go-errors v1.0.3 + github.com/fatih/color v1.9.0 github.com/mutablelogic/go-client v1.0.10 github.com/stretchr/testify v1.10.0 golang.org/x/term v0.28.0 @@ -11,8 +14,11 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/djthorpe/go-errors v1.0.3 // indirect + github.com/mattn/go-colorable v0.1.4 // indirect + github.com/mattn/go-isatty v0.0.11 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect golang.org/x/sys v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 78507c5..e9cc101 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,48 @@ +github.com/MichaelMure/go-term-text v0.3.1 h1:Kw9kZanyZWiCHOYu9v/8pWEgDQ6UVN9/ix2Vd2zzWf0= +github.com/MichaelMure/go-term-text v0.3.1/go.mod h1:QgVjAEDUnRMlzpS6ky5CGblux7ebeiLnuy9dAaFZu8o= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.7.0 h1:MnT8+5JxFDCvISeI6vgd/mFbAJwueJ/pqQNzZMsiqZE= github.com/alecthomas/kong v1.7.0/go.mod h1:p2vqieVMeTAnaC83txKtXe8FLke2X07aruPWXyMPQrU= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/djthorpe/go-errors v1.0.3 h1:GZeMPkC1mx2vteXLI/gvxZS0Ee9zxzwD1mcYyKU5jD0= github.com/djthorpe/go-errors v1.0.3/go.mod h1:HtfrZnMd6HsX75Mtbv9Qcnn0BqOrrFArvCaj3RMnZhY= +github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mutablelogic/go-client v1.0.10 h1:d4t8irXlGNQrQS/+FoUht+1RnjL9lBaf1e2UasN3ifE= github.com/mutablelogic/go-client v1.0.10/go.mod h1:XbG8KGo2Efi7PGxXs7rhYxYhLeXL6aCSo6sz0mVchiw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=