diff --git a/.gitignore b/.gitignore index 5451df0..6da1773 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ go.work.sum # env file .env +/tmp \ No newline at end of file diff --git a/agents/agents.go b/agents/agents.go index 04832d3..a560401 100644 --- a/agents/agents.go +++ b/agents/agents.go @@ -10,18 +10,22 @@ import ( "context" "errors" "fmt" - "strings" "github.com/bit8bytes/gogantic/agents/tools" "github.com/bit8bytes/gogantic/inputs/roles" "github.com/bit8bytes/gogantic/llms" - "github.com/bit8bytes/gogantic/outputs/jsonout" ) type llm interface { Generate(ctx context.Context, messages []llms.Message) (*llms.ContentResponse, error) } +type store interface { + Add(ctx context.Context, msgs ...llms.Message) error + List(ctx context.Context) ([]llms.Message, error) + Clear(ctx context.Context) error +} + // Tool represents an action the agent can perform. // Each tool must provide a name, description, and execution logic. type Tool interface { @@ -41,40 +45,41 @@ type parser interface { type Agent struct { model llm tools map[string]Tool - Messages []llms.Message + History store actions []Action parser parser finalAnswer string } -// New creates an agent with the given model and tools. -// The agent is initialized with a ReAct system prompt. -func New(model llm, tools []Tool) *Agent { - p := jsonout.NewParser[AgentResponse]() - t := toolNames(tools) - +// New creates an agent with the given model, tools, storage, and parser. +// For the ReAct pattern, prefer NewReAct. +func New(model llm, tools []Tool, storage store, p parser) *Agent { return &Agent{ - model: model, - tools: t, - Messages: buildReActPrompt(t, p.Instructions()), - parser: p, + model: model, + tools: toolNames(tools), + History: storage, + parser: p, } } // Task sets the user's question or task for the agent to solve. // Call this before starting the Plan-Act loop. -func (a *Agent) Task(prompt string) error { - a.Messages = append(a.Messages, llms.Message{ +func (a *Agent) Task(ctx context.Context, prompt string) error { + return a.History.Add(ctx, llms.Message{ Role: roles.User, Content: "Question: " + prompt, }) - return nil } // Plan calls the LLM to decide the next action or provide a final answer. // Returns Response.Finish=true when the task is complete. func (a *Agent) Plan(ctx context.Context) (*Response, error) { - generated, err := a.model.Generate(ctx, a.Messages) + history, err := a.History.List(ctx) + if err != nil { + return nil, err + } + + generated, err := a.model.Generate(ctx, history) if err != nil { return nil, err } @@ -84,7 +89,7 @@ func (a *Agent) Plan(ctx context.Context) (*Response, error) { return nil, fmt.Errorf("failed to parse agent response: %w", err) } - a.addAssistantMessage(generated.Result) + a.addAssistantMessage(ctx, generated.Result) if parsed.FinalAnswer != "" { a.finalAnswer = parsed.FinalAnswer @@ -119,7 +124,7 @@ func (a *Agent) Act(ctx context.Context) { func (a *Agent) handleAction(ctx context.Context, action Action) bool { t, exists := a.tools[action.Tool] if !exists { - a.addObservationMessage("The action " + action.Tool + " doesn't exist.") + a.addObservationMessage(ctx, "The action "+action.Tool+" doesn't exist.") return false } @@ -127,11 +132,11 @@ func (a *Agent) handleAction(ctx context.Context, action Action) bool { Content: action.ToolInput, }) if err != nil { - a.addObservationMessage("Error: " + err.Error()) + a.addObservationMessage(ctx, "Error: "+err.Error()) return false } - a.addObservationMessage(observation.Content) + a.addObservationMessage(ctx, observation.Content) return true } @@ -148,34 +153,6 @@ func (a *Agent) Answer() (string, error) { return a.finalAnswer, nil } -func buildReActPrompt(tools map[string]Tool, jsonInstructions string) []llms.Message { - var toolDescriptions strings.Builder - for _, t := range tools { - fmt.Fprintf(&toolDescriptions, "- %s: %s\n", t.Name(), t.Description()) - } - - return []llms.Message{ - { - Role: roles.System, - Content: fmt.Sprintf(` -You are an helpful agent. Answer questions using the available tools. -Do not estimate or predict values. Use only values returned by tools. - -Available tools: -%s -%s - -Respond with a JSON object on each turn with these fields: -- "thought": your reasoning about what to do next -- "action": the exact tool name to call (empty string when giving final answer) -- "action_input": the input to pass to the tool (empty string when giving final answer) -- "final_answer": your final answer (empty string when calling a tool) - -Think step by step. Do not hallucinate.`, toolDescriptions.String(), jsonInstructions), - }, - } -} - func toolNames(tools []Tool) map[string]Tool { t := make(map[string]Tool, len(tools)) for _, tool := range tools { diff --git a/agents/messages.go b/agents/messages.go index 12fe9aa..740ce16 100644 --- a/agents/messages.go +++ b/agents/messages.go @@ -1,19 +1,21 @@ package agents import ( + "context" + "github.com/bit8bytes/gogantic/inputs/roles" "github.com/bit8bytes/gogantic/llms" ) -func (a *Agent) addAssistantMessage(content string) { - a.Messages = append(a.Messages, llms.Message{ +func (a *Agent) addAssistantMessage(ctx context.Context, content string) { + a.History.Add(ctx, llms.Message{ Role: roles.Assistent, Content: content, }) } -func (a *Agent) addObservationMessage(observation string) { - a.Messages = append(a.Messages, llms.Message{ +func (a *Agent) addObservationMessage(ctx context.Context, observation string) { + a.History.Add(ctx, llms.Message{ Role: roles.System, Content: "Observation: " + observation, }) diff --git a/agents/react.go b/agents/react.go new file mode 100644 index 0000000..6c942cc --- /dev/null +++ b/agents/react.go @@ -0,0 +1,58 @@ +package agents + +import ( + "context" + "fmt" + "strings" + + "github.com/bit8bytes/gogantic/inputs/roles" + "github.com/bit8bytes/gogantic/llms" + "github.com/bit8bytes/gogantic/outputs/jsonout" +) + +// NewReAct creates an agent pre-configured for the ReAct pattern. +// It seeds the ReAct system prompt into storage. +func NewReAct(ctx context.Context, model llm, tools []Tool, storage store) (*Agent, error) { + p := jsonout.NewParser[AgentResponse]() + t := toolNames(tools) + + msgs := buildReActPrompt(t, p.Instructions()) + if err := storage.Add(ctx, msgs...); err != nil { + return nil, err + } + + return &Agent{ + model: model, + tools: t, + History: storage, + parser: p, + }, nil +} + +func buildReActPrompt(tools map[string]Tool, jsonInstructions string) []llms.Message { + var toolDescriptions strings.Builder + for _, t := range tools { + fmt.Fprintf(&toolDescriptions, "- %s: %s\n", t.Name(), t.Description()) + } + + return []llms.Message{ + { + Role: roles.System, + Content: fmt.Sprintf(` +You are an helpful agent. Answer questions using the available tools. +Do not estimate or predict values. Use only values returned by tools. + +Available tools: +%s +%s + +Respond with a JSON object on each turn with these fields: +- "thought": your reasoning about what to do next +- "action": the exact tool name to call (empty string when giving final answer) +- "action_input": the input to pass to the tool (empty string when giving final answer) +- "final_answer": your final answer (empty string when calling a tool) + +Think step by step. Do not hallucinate.`, toolDescriptions.String(), jsonInstructions), + }, + } +} diff --git a/examples/agents/ollama/grep/main.go b/examples/agents/ollama/grep/main.go index 08ce16b..e740088 100644 --- a/examples/agents/ollama/grep/main.go +++ b/examples/agents/ollama/grep/main.go @@ -2,35 +2,61 @@ package main import ( "context" + "database/sql" "fmt" "time" "github.com/bit8bytes/gogantic/agents" "github.com/bit8bytes/gogantic/llms/ollama" "github.com/bit8bytes/gogantic/runner" + "github.com/bit8bytes/gogantic/stores" + modernc "github.com/bit8bytes/gogantic/stores/moderncsqlite" + _ "github.com/bit8bytes/gogantic/stores/moderncsqlite" ) func main() { - llm := ollama.New(ollama.Model{ + ctx, cancel := context.WithTimeout(context.Background(), time.Second*60) + defer cancel() + + db, err := sql.Open(modernc.Sqlite, ":memory:") + if err != nil { + panic(err) + } + defer db.Close() + + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + );`) + if err != nil { + panic(err) + } + + storage, err := stores.New(ctx, modernc.Sqlite, db) + if err != nil { + panic(err) + } + defer storage.Close() + + model := ollama.New(ollama.Model{ Model: "gemma3n:e2b", Options: ollama.Options{NumCtx: 4096}, Stream: false, Format: "json", }) - tools := []agents.Tool{ - ListDir{}, + agent, err := agents.NewReAct(ctx, model, []agents.Tool{ListDir{}}, storage) + if err != nil { + panic(err) } - task := "List all files in folder agents/" - agent := agents.New(llm, tools) - if err := agent.Task(task); err != nil { + if err := agent.Task(ctx, "List all files in folder agents/"); err != nil { panic(err) } - ctx, cancel := context.WithTimeout(context.TODO(), time.Second*60) - defer cancel() - r := runner.New(agent, true) if err := r.Run(ctx); err != nil { panic(err) diff --git a/go.mod b/go.mod index 77c4219..2c34d5a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,17 @@ module github.com/bit8bytes/gogantic go 1.25.7 + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.37.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect +) diff --git a/go.sum b/go.sum index e69de29..5cf3e13 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/grep b/grep deleted file mode 100755 index 2b20d3d..0000000 Binary files a/grep and /dev/null differ diff --git a/inputs/roles/roles.go b/inputs/roles/roles.go index 57c310c..947b7cc 100644 --- a/inputs/roles/roles.go +++ b/inputs/roles/roles.go @@ -7,3 +7,7 @@ const ( User Role = "user" Assistent Role = "assistent" ) + +func (r Role) String() string { + return string(r) +} diff --git a/runner/runner.go b/runner/runner.go index 1d98a17..cfc8231 100644 --- a/runner/runner.go +++ b/runner/runner.go @@ -8,7 +8,6 @@ import ( "fmt" "github.com/bit8bytes/gogantic/agents" - "github.com/bit8bytes/gogantic/inputs/roles" ) const ( @@ -53,39 +52,7 @@ RUN: } r.agent.Act(ctx) - - r.printNewMessages() } } return fmt.Errorf("no final answer available") } - -func (r *runner) printNewMessages() { - if !r.printMessages { - return - } - - messages := r.agent.Messages - startIdx := r.lastPrintedMsgIdx + 1 - - for i := startIdx; i < len(messages); i++ { - msg := messages[i] - color := r.getColorForRole(msg.Role) - fmt.Printf("%s%s: %s%s\n", color, msg.Role, msg.Content, reset) - } - - r.lastPrintedMsgIdx = len(messages) - 1 -} - -func (r *runner) getColorForRole(role roles.Role) string { - switch role { - case roles.Assistent: - return blue - case roles.System: - return green - case roles.User: - return cyan - default: - return white - } -} diff --git a/stores/moderncsqlite/moderncsqlite.go b/stores/moderncsqlite/moderncsqlite.go new file mode 100644 index 0000000..d8e7504 --- /dev/null +++ b/stores/moderncsqlite/moderncsqlite.go @@ -0,0 +1,66 @@ +package moderncsqlite + +import ( + "context" + "database/sql" + + "github.com/bit8bytes/gogantic/llms" + "github.com/bit8bytes/gogantic/stores" + _ "modernc.org/sqlite" +) + +const Sqlite = "sqlite" + +func init() { + stores.Register(Sqlite, NewSqlite) +} + +type sqliteStore struct { + db *sql.DB +} + +func NewSqlite(ctx context.Context, name string, db *sql.DB) (stores.Store, error) { + return &sqliteStore{db: db}, nil +} + +func (s *sqliteStore) Add(ctx context.Context, messages ...llms.Message) error { + for _, msg := range messages { + _, err := s.db.ExecContext(ctx, + `INSERT INTO messages (role, content) VALUES (?, ?)`, + msg.Role.String(), msg.Content, + ) + if err != nil { + return err + } + } + return nil +} + +func (s *sqliteStore) List(ctx context.Context) ([]llms.Message, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT role, content FROM messages ORDER BY id ASC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var msgs []llms.Message + for rows.Next() { + var msg llms.Message + if err := rows.Scan(&msg.Role, &msg.Content); err != nil { + return nil, err + } + msgs = append(msgs, msg) + } + return msgs, rows.Err() +} + +func (s *sqliteStore) Clear(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM messages`) + return err +} + +func (s *sqliteStore) Close() error { + return s.db.Close() +} diff --git a/stores/stores.go b/stores/stores.go new file mode 100644 index 0000000..5eb3f46 --- /dev/null +++ b/stores/stores.go @@ -0,0 +1,88 @@ +package stores + +import ( + "context" + "database/sql" + "fmt" + "sync" + + "github.com/bit8bytes/gogantic/llms" +) + +// Store is the interface that wraps the basic message persistence methods. +// +// Implementations of Store must be safe for concurrent use by multiple goroutines. +type Store interface { + Add(ctx context.Context, msgs ...llms.Message) error + List(ctx context.Context) ([]llms.Message, error) + Clear(ctx context.Context) error + Close() error +} + +// Memory is a simple in-memory store backed by a slice. +type Memory struct { + mu sync.Mutex + msgs []llms.Message +} + +// NewMemory returns a new in-memory store. +func NewMemory() *Memory { + return &Memory{} +} + +func (m *Memory) Add(_ context.Context, msgs ...llms.Message) error { + m.mu.Lock() + defer m.mu.Unlock() + m.msgs = append(m.msgs, msgs...) + return nil +} + +func (m *Memory) List(_ context.Context) ([]llms.Message, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]llms.Message, len(m.msgs)) + copy(out, m.msgs) + return out, nil +} + +func (m *Memory) Clear(_ context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.msgs = nil + return nil +} + +func (m *Memory) Close() error { return nil } + +// Opener defines the functional factory for creating a Store backed by a sql.DB. +type Opener func(ctx context.Context, name string, db *sql.DB) (Store, error) + +var ( + mu sync.RWMutex + drivers = make(map[string]Opener) +) + +// Register makes a store driver available by the provided name. +// If Register is called twice with the same name or if driver is nil, it panics. +func Register(name string, fn Opener) { + mu.Lock() + defer mu.Unlock() + if fn == nil { + panic("store: Register driver is nil") + } + if _, dup := drivers[name]; dup { + panic("store: Register called twice for driver " + name) + } + drivers[name] = fn +} + +// New opens a store specified by its driver name. +func New(ctx context.Context, name string, db *sql.DB) (Store, error) { + mu.RLock() + fn, ok := drivers[name] + mu.RUnlock() + if !ok { + return nil, fmt.Errorf("store: unknown driver %q (forgotten import?)", name) + } + return fn(ctx, name, db) +}