Compare commits

...

2 Commits

Author SHA1 Message Date
1c3ea7d1f1 Refactor Knowledge struct into shared package
Moved the Knowledge struct and related types to the shared package, updating all references across the codebase. This improves modularity and enables better reuse of the Knowledge type across different components.
2025-05-03 22:09:02 -04:00
d2b9eb350e Refactor console agents for improved modularity and configuration
Consolidated and refactored console agent logic into a streamlined structure with better configuration capabilities via `ConsoleConfig`. Improved code reusability and readability by converting nested structures and functions, and introduced more modular execution types for handling commands and history tracking.
2025-05-03 22:06:32 -04:00
13 changed files with 344 additions and 377 deletions

View File

@@ -9,6 +9,7 @@ import (
"time"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/console"
@@ -102,7 +103,7 @@ func main() {
c := console.Agent{
Model: m,
OnDone: func(ctx context.Context, knowledge agents.Knowledge) error { return nil },
OnDone: func(ctx context.Context, knowledge shared.Knowledge) error { return nil },
OnCommandStart: func(ctx context.Context, command string) error {
slog.Info("command", "command", command)
return nil

View File

@@ -23,12 +23,12 @@ type Agent struct {
// Model is the chat completion model to use
Model gollm.ChatCompletion
OnLoopComplete func(ctx context.Context, knowledge agents.Knowledge) error
OnLoopComplete func(ctx context.Context, knowledge shared.Knowledge) error
OnCommandStart func(ctx context.Context, command string) error
OnCommandDone func(ctx context.Context, command string, output string, err error) error
OnDone func(ctx context.Context, knowledge agents.Knowledge) error
OnDone func(ctx context.Context, knowledge shared.Knowledge) error
ContextualInformation []string
@@ -36,7 +36,7 @@ type Agent struct {
}
type Response struct {
Knowledge agents.Knowledge
Knowledge shared.Knowledge
DataDir string
OutputDir string
}
@@ -52,7 +52,7 @@ func (a Agent) Answer(ctx context.Context, questions []string) (Response, error)
a.MaxCommands = 20 // Default to 20 commands as per requirements
}
res.Knowledge = agents.Knowledge{
res.Knowledge = shared.Knowledge{
OriginalQuestions: questions,
RemainingQuestions: questions,
}

View File

@@ -2,24 +2,284 @@ package agents
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/console"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
// Console will construct a console.Agent, execute the agent, and return the knowledge gained, the output directory,
// and any possible error.
func (a Agent) Console(ctx context.Context, questions []string) (Knowledge, string, error) {
con := console.Agent{
Agent: a,
Model: a.model,
ContextualInformation: a.contextualInformation,
MaxCommands: 50,
type ConsoleConfig struct {
// MaxCommands is how many total commands this console can run.
MaxCommands int `json:"max_commands"`
OnCommandStart func(ctx context.Context, command string) error
OnCommandDone func(ctx context.Context, command string, output string, err error) error
}
res, err := con.Answer(ctx, questions)
func (a Agent) Console(ctx context.Context, questions []string, cfg ConsoleConfig) (shared.Knowledge, string, error) {
if cfg.MaxCommands <= 0 {
cfg.MaxCommands = 10000
}
var resKnowledge = shared.Knowledge{
OriginalQuestions: questions,
RemainingQuestions: questions,
}
// create a temporary scratch directory
dir, err := os.MkdirTemp("", "console-")
if err != nil {
return Knowledge{}, "", err
return resKnowledge, "", err
}
return res.Knowledge, res.Directory, nil
var resDirectory = dir
cl, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return resKnowledge, resDirectory, err
}
defer cl.Close()
mounts := []mount.Mount{
{
Type: mount.TypeBind,
Source: dir,
Target: "/home/user",
},
}
c, err := console.CreateContainer(ctx, cl, console.ContainerConfig{
Config: &container.Config{
Image: "ubuntu:latest",
Cmd: []string{"tail", "-f", "/dev/null"},
Tty: true,
WorkingDir: "/home/user",
},
HostConfig: &container.HostConfig{
AutoRemove: true,
Mounts: mounts,
},
Name: filepath.Base(dir),
})
if err != nil {
return resKnowledge, resDirectory, fmt.Errorf("failed to create container: %w", err)
}
defer func() {
_ = c.Close(ctx)
}()
slog.Info("starting container", "dir", dir, "container", fmt.Sprintf("%+v", c))
err = c.Start(ctx)
if err != nil {
return resKnowledge, resDirectory, fmt.Errorf("failed to start container: %w", err)
}
// Run the model
var history console.Executions
var keepGoing = true
opwd, epwd := c.Execute(ctx, "ls -al /home")
fmt.Println(opwd)
slog.Info("pwd", "pwd", opwd, "epwd", epwd)
tools := map[string]gollm.Function{
"exit": gollm.NewFunction(
"exit",
"exit the container",
func(ctx *gollm.Context, args struct {
RemainingQuestions []string `description:"any remaining questions that remain unanswered"`
}) (any, error) {
keepGoing = false
return "exiting", nil
}),
"write": gollm.NewFunction(
"write",
"write a file in the /root directory",
func(ctx *gollm.Context, args struct {
Filename string `description:"The name of the file to write"`
Content string `description:"The content of the file to write"`
}) (any, error) {
target, err := console.SafeJoinPath(dir, args.Filename)
if err != nil {
return "", err
}
f, err := os.Create(target)
if err != nil {
return "", err
}
defer f.Close()
_, err = f.WriteString(args.Content)
if err != nil {
return "", err
}
return "wrote file", nil
}),
"read": gollm.NewFunction(
"read",
"read a file in the /root directory",
func(ctx *gollm.Context, args struct {
Filename string `description:"The name of the file to read"`
}) (any, error) {
target, err := console.SafeJoinPath(dir, args.Filename)
if err != nil {
return "", err
}
b, err := os.ReadFile(target)
if err != nil {
return "", err
}
return string(b), nil
}),
"execute": gollm.NewFunction(
"execute",
"execute a command in the container",
func(ctx *gollm.Context, args struct {
Command string `description:"The command to execute"`
}) (any, error) {
if len(history) >= cfg.MaxCommands {
return "too many commands", nil
}
if cfg.OnCommandStart != nil {
err := cfg.OnCommandStart(ctx, args.Command)
if err != nil {
return "", err
}
}
var res string
// if the command starts with sudo then we need to use the sudo function
if strings.HasPrefix(args.Command, "sudo ") {
res, err = c.Sudo(ctx, args.Command[5:])
} else {
res, err = c.Execute(ctx, args.Command)
}
if cfg.OnCommandDone != nil {
err = cfg.OnCommandDone(ctx, args.Command, res, nil)
if err != nil {
return "", err
}
}
history = append(history, console.Execution{
Command: args.Command,
Output: res,
})
return res, nil
}),
"sudo": gollm.NewFunction(
"sudo",
"execute a command in the container",
func(ctx *gollm.Context, args struct {
Command string `description:"The command to execute"`
}) (any, error) {
if len(history) >= cfg.MaxCommands {
return "too many commands", nil
}
if cfg.OnCommandStart != nil {
err := cfg.OnCommandStart(ctx, args.Command)
if err != nil {
return "", err
}
}
res, err := c.Sudo(ctx, args.Command)
if cfg.OnCommandDone != nil {
err = cfg.OnCommandDone(ctx, args.Command, res, nil)
if err != nil {
return "", err
}
}
if err != nil {
res = "error executing: " + err.Error()
}
history = append(history, console.Execution{
Command: "sudo " + args.Command,
Output: res,
})
return res, nil
}),
}
for i := 0; i < cfg.MaxCommands && len(history) < cfg.MaxCommands && keepGoing; i++ {
systemPrompt := `You are now in a shell in a container of the ubuntu:latest image to answer a question asked by the user, it is very basic install of ubuntu, simple things (like python) are not preinstalled but can be installed via apt. You will be run multiple times and gain knowledge throughout the process.`
if len(history) < cfg.MaxCommands {
systemPrompt += `You can run any command you like to get to the needed results.`
}
systemPrompt += `Alternatively, you can use the tool "write" to write a file in the home directory, and also the tool "read" to read a file in the home directory.
When you are done, please use "exit" to exit the container.
Respond with any number of commands to answer the question, they will be executed in order.`
var toolbox []gollm.Function
// add unrestricted tools
toolbox = append(toolbox, tools["exit"], tools["write"], tools["read"])
if len(history) < cfg.MaxCommands {
toolbox = append(toolbox, tools["execute"], tools["sudo"])
}
kw := shared.KnowledgeWorker{
Model: a.model,
ToolBox: gollm.NewToolBox(toolbox...),
ContextualInformation: a.contextualInformation,
OnNewFunction: func(ctx context.Context, funcName string, args string) (any, error) {
slog.Info("new function called", "function name", funcName, "args", args)
return nil, nil
},
}
slog.Info("answering question", "question", questions[0])
r, err := kw.Answer(ctx, &resKnowledge, systemPrompt, "", "", history.ToGeneralButLastMessageHistory(), func(res gollm.ToolCallResponse) {
})
if err != nil {
return resKnowledge, resDirectory, fmt.Errorf("error answering question: %w", err)
}
if len(r.Knowledge) > 0 {
slog.Info("answered question and learned", "knowledge", r.Knowledge)
} else {
slog.Info("answered question and learned nothing")
}
resKnowledge, err = a.KnowledgeIntegrate(ctx, resKnowledge, r)
if err != nil {
return resKnowledge, resDirectory, fmt.Errorf("error integrating knowledge: %w", err)
}
slog.Info("knowledge integrated", "question", questions[0], "knowledge", resKnowledge)
}
return resKnowledge, resDirectory, nil
}

View File

@@ -1,305 +0,0 @@
package console
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
type Agent struct {
agents.Agent
// Model is the chat completion model to use
Model gollm.ChatCompletion
OnLoopComplete func(ctx context.Context, knowledge agents.Knowledge) error
OnCommandStart func(ctx context.Context, command string) error
OnCommandDone func(ctx context.Context, command string, output string, err error) error
OnDone func(ctx context.Context, knowledge agents.Knowledge) error
ContextualInformation []string
MaxCommands int
}
type Response struct {
Knowledge agents.Knowledge
Directory string
}
// Answer will give the model access to an ubuntu console with python and pip installed, and then ask the model to
// do what is necessary to answer the question.
func (a Agent) Answer(ctx context.Context, questions []string) (Response, error) {
var res Response
a.Agent = agents.NewAgent(a.Model, gollm.NewToolBox()).WithMaxCalls(200)
if a.MaxCommands <= 0 {
a.MaxCommands = 10000
}
res.Knowledge = agents.Knowledge{
OriginalQuestions: questions,
RemainingQuestions: questions,
}
// create a temporary scratch directory
dir, err := os.MkdirTemp("", "console-")
if err != nil {
return res, err
}
res.Directory = dir
cl, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return res, err
}
defer cl.Close()
mounts := []mount.Mount{
{
Type: mount.TypeBind,
Source: dir,
Target: "/home/user",
},
}
c, err := CreateContainer(ctx, cl, ContainerConfig{
Config: &container.Config{
Image: "ubuntu:latest",
Cmd: []string{"tail", "-f", "/dev/null"},
Tty: true,
WorkingDir: "/home/user",
},
HostConfig: &container.HostConfig{
AutoRemove: true,
Mounts: mounts,
},
Name: filepath.Base(dir),
})
if err != nil {
return res, fmt.Errorf("failed to create container: %w", err)
}
defer func() {
_ = c.Close(ctx)
}()
slog.Info("starting container", "dir", dir, "container", fmt.Sprintf("%+v", c))
err = c.Start(ctx)
if err != nil {
return res, fmt.Errorf("failed to start container: %w", err)
}
// Run the model
var history executions
var keepGoing = true
opwd, epwd := c.Execute(ctx, "ls -al /home")
fmt.Println(opwd)
slog.Info("pwd", "pwd", opwd, "epwd", epwd)
tools := map[string]gollm.Function{
"exit": gollm.NewFunction(
"exit",
"exit the container",
func(ctx *gollm.Context, args struct {
RemainingQuestions []string `description:"any remaining questions that remain unanswered"`
}) (any, error) {
keepGoing = false
return "exiting", nil
}),
"write": gollm.NewFunction(
"write",
"write a file in the /root directory",
func(ctx *gollm.Context, args struct {
Filename string `description:"The name of the file to write"`
Content string `description:"The content of the file to write"`
}) (any, error) {
target, err := SafeJoinPath(dir, args.Filename)
if err != nil {
return "", err
}
f, err := os.Create(target)
if err != nil {
return "", err
}
defer f.Close()
_, err = f.WriteString(args.Content)
if err != nil {
return "", err
}
return "wrote file", nil
}),
"read": gollm.NewFunction(
"read",
"read a file in the /root directory",
func(ctx *gollm.Context, args struct {
Filename string `description:"The name of the file to read"`
}) (any, error) {
target, err := SafeJoinPath(dir, args.Filename)
if err != nil {
return "", err
}
b, err := os.ReadFile(target)
if err != nil {
return "", err
}
return string(b), nil
}),
"execute": gollm.NewFunction(
"execute",
"execute a command in the container",
func(ctx *gollm.Context, args struct {
Command string `description:"The command to execute"`
}) (any, error) {
if len(history) >= a.MaxCommands {
return "too many commands", nil
}
if a.OnCommandStart != nil {
err := a.OnCommandStart(ctx, args.Command)
if err != nil {
return "", err
}
}
var res string
// if the command starts with sudo then we need to use the sudo function
if strings.HasPrefix(args.Command, "sudo ") {
res, err = c.Sudo(ctx, args.Command[5:])
} else {
res, err = c.Execute(ctx, args.Command)
}
if a.OnCommandDone != nil {
err = a.OnCommandDone(ctx, args.Command, res, nil)
if err != nil {
return "", err
}
}
history = append(history, execution{
Command: args.Command,
Output: res,
})
return res, nil
}),
"sudo": gollm.NewFunction(
"sudo",
"execute a command in the container",
func(ctx *gollm.Context, args struct {
Command string `description:"The command to execute"`
}) (any, error) {
if len(history) >= a.MaxCommands {
return "too many commands", nil
}
if a.OnCommandStart != nil {
err := a.OnCommandStart(ctx, args.Command)
if err != nil {
return "", err
}
}
res, err := c.Sudo(ctx, args.Command)
if a.OnCommandDone != nil {
err = a.OnCommandDone(ctx, args.Command, res, nil)
if err != nil {
return "", err
}
}
if err != nil {
res = "error executing: " + err.Error()
}
history = append(history, execution{
Command: "sudo " + args.Command,
Output: res,
})
return res, nil
}),
}
for i := 0; i < a.MaxCommands && len(history) < a.MaxCommands && keepGoing; i++ {
systemPrompt := `You are now in a shell in a container of the ubuntu:latest image to answer a question asked by the user, it is very basic install of ubuntu, simple things (like python) are not preinstalled but can be installed via apt. You will be run multiple times and gain knowledge throughout the process.`
if len(history) < a.MaxCommands {
systemPrompt += `You can run any command you like to get to the needed results.`
}
systemPrompt += `Alternatively, you can use the tool "write" to write a file in the home directory, and also the tool "read" to read a file in the home directory.
When you are done, please use "exit" to exit the container.
Respond with any number of commands to answer the question, they will be executed in order.`
var toolbox []gollm.Function
// add unrestricted tools
toolbox = append(toolbox, tools["exit"], tools["write"], tools["read"])
if len(history) < a.MaxCommands {
toolbox = append(toolbox, tools["execute"], tools["sudo"])
}
kw := shared.KnowledgeWorker{
Model: a.Model,
ToolBox: gollm.NewToolBox(toolbox...),
ContextualInformation: a.ContextualInformation,
OnNewFunction: func(ctx context.Context, funcName string, args string) (any, error) {
slog.Info("new function called", "function name", funcName, "args", args)
return nil, nil
},
}
slog.Info("answering question", "question", questions[0])
r, err := kw.Answer(ctx, &res.Knowledge, systemPrompt, "", "", history.ToGeneralButLastMessageHistory(), func(res gollm.ToolCallResponse) {
})
if err != nil {
return res, fmt.Errorf("error answering question: %w", err)
}
if len(r.Knowledge) > 0 {
slog.Info("answered question and learned", "knowledge", r.Knowledge)
} else {
slog.Info("answered question and learned nothing")
}
res.Knowledge, err = a.KnowledgeIntegrate(ctx, res.Knowledge, r)
if err != nil {
return res, fmt.Errorf("error integrating knowledge: %w", err)
}
slog.Info("knowledge integrated", "question", questions[0], "knowledge", res.Knowledge)
}
return res, nil
}

View File

@@ -6,7 +6,7 @@ import (
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
type execution struct {
type Execution struct {
Command string
Output string
WhatILearned []string
@@ -16,7 +16,7 @@ type execution struct {
const kMaxLenCommandSummary = 200
const kMaxLenCommandOutputSummary = 200
func (e execution) ToGeneralMessageHistory() gollm.Message {
func (e Execution) ToGeneralMessageHistory() gollm.Message {
if len(e.Command) > kMaxLenCommandSummary {
e.Command = e.Command[:kMaxLenCommandSummary] + "... (truncated)"
}
@@ -33,7 +33,7 @@ func (e execution) ToGeneralMessageHistory() gollm.Message {
}
}
func (e execution) ToDetailedMessageHistory() gollm.Message {
func (e Execution) ToDetailedMessageHistory() gollm.Message {
prompt := "$ "
if strings.HasPrefix(e.Command, "sudo ") {
prompt = "# "
@@ -60,9 +60,9 @@ func (e execution) ToDetailedMessageHistory() gollm.Message {
}
}
type executions []execution
type Executions []Execution
func (e executions) ToGeneralMessageHistory() []gollm.Message {
func (e Executions) ToGeneralMessageHistory() []gollm.Message {
var messages []gollm.Message
for _, v := range e {
@@ -72,7 +72,7 @@ func (e executions) ToGeneralMessageHistory() []gollm.Message {
return messages
}
func (e executions) ToGeneralButLastMessageHistory() []gollm.Message {
func (e Executions) ToGeneralButLastMessageHistory() []gollm.Message {
var messages []gollm.Message
for i, v := range e {
@@ -86,7 +86,7 @@ func (e executions) ToGeneralButLastMessageHistory() []gollm.Message {
return messages
}
func (e executions) ToDetailedMessageHistory() []gollm.Message {
func (e Executions) ToDetailedMessageHistory() []gollm.Message {
var messages []gollm.Message
for _, v := range e {

View File

@@ -5,6 +5,8 @@ import (
"strings"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
// ExtractKnowledge will take a knowledge object and use the gained knowledge to extract the knowledge relevant to the
@@ -16,16 +18,16 @@ import (
// contextualInformation is any contextual information that should be provided to the model.
// It will return the knowledge extracted from the sourceData along with any remaining questions.
// This agent call will not use the Agent's system prompts, but will instead form its own. The contextual information will be used.
func (a Agent) ExtractKnowledge(ctx context.Context, sourceData string, source string, questions []string) (Knowledge, error) {
func (a Agent) ExtractKnowledge(ctx context.Context, sourceData string, source string, questions []string) (shared.Knowledge, error) {
var knowledge Knowledge
var knowledge shared.Knowledge
fnAnswer := gollm.NewFunction(
"learn",
`Use learn to pass some relevant information to the model. The model will use this information to answer the question. Use it to learn relevant information from the text. Keep these concise and relevant to the question.`,
func(ctx *gollm.Context, args struct {
Info string `description:"The information to learn from the text."`
}) (any, error) {
knowledge.Knowledge = append(knowledge.Knowledge, TidBit{Info: args.Info, Source: source})
knowledge.Knowledge = append(knowledge.Knowledge, shared.TidBit{Info: args.Info, Source: source})
return "", nil
})

View File

@@ -4,12 +4,14 @@ import (
"context"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
// KnowledgeIntegrate will ask the LLM to combine the gained knowledge with the current knowledge, and return the new representation of overall.
// If source is not empty, then any new Knowledge will an empty source will be given the source.
// This will override objectives, notes, and remaining questions.
func (a Agent) KnowledgeIntegrate(ctx context.Context, base Knowledge, in ...Knowledge) (Knowledge, error) {
func (a Agent) KnowledgeIntegrate(ctx context.Context, base shared.Knowledge, in ...shared.Knowledge) (shared.Knowledge, error) {
// if there are no changes we can just return the knowledge
if len(in) == 0 {
return base, nil
@@ -29,7 +31,7 @@ func (a Agent) KnowledgeIntegrate(ctx context.Context, base Knowledge, in ...Kno
}
}
var incoming Knowledge
var incoming shared.Knowledge
for _, k := range in {
incoming.NotesToSelf = append(incoming.NotesToSelf, k.NotesToSelf...)
@@ -43,7 +45,7 @@ func (a Agent) KnowledgeIntegrate(ctx context.Context, base Knowledge, in ...Kno
baseMsg.Text = "The original knowledge is as follows: " + baseMsg.Text
incomingMsg.Text = "The new knowledge is as follows: " + incomingMsg.Text
var result = Knowledge{
var result = shared.Knowledge{
OriginalQuestions: base.OriginalQuestions,
Knowledge: append(base.Knowledge, incoming.Knowledge...),
}

View File

@@ -8,10 +8,12 @@ import (
"strings"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
// AnswerQuestionWithKnowledge will take a knowledge object and use the gained knowledge to answer a question.
func (a Agent) AnswerQuestionWithKnowledge(ctx context.Context, knowledge Knowledge) (string, error) {
func (a Agent) AnswerQuestionWithKnowledge(ctx context.Context, knowledge shared.Knowledge) (string, error) {
originalQuestions := strings.Join(knowledge.OriginalQuestions, "\n")
infoGained := ""

View File

@@ -3,19 +3,21 @@ package agents
import (
"context"
"fmt"
"net/url"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/cache"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/extractor"
"net/url"
)
func (a Agent) ReadPage(ctx context.Context, u *url.URL, questions []string) (Knowledge, error) {
func (a Agent) ReadPage(ctx context.Context, u *url.URL, questions []string) (shared.Knowledge, error) {
ar, err := extractArticle(ctx, u)
if err != nil {
return Knowledge{}, err
return shared.Knowledge{}, err
}
if ar.Body == "" {
return Knowledge{}, fmt.Errorf("could not extract body from page")
return shared.Knowledge{}, fmt.Errorf("could not extract body from page")
}
return a.ExtractKnowledge(ctx, ar.Body, u.String(), questions)

View File

@@ -14,6 +14,8 @@ import (
"gitea.stevedudenhoeffer.com/steve/go-extractor"
"gitea.stevedudenhoeffer.com/steve/go-extractor/sites/duckduckgo"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
func deferClose(c io.Closer) {
@@ -26,7 +28,7 @@ func deferClose(c io.Closer) {
type SearchTool struct {
Name string
Description string
Function func(ctx context.Context, src *url.URL, questions []string) (Knowledge, error)
Function func(ctx context.Context, src *url.URL, questions []string) (shared.Knowledge, error)
}
// SearchAndUseTools will search duckduckgo for the given question, and then ask the LLM to select a search result to
@@ -41,8 +43,8 @@ type SearchTool struct {
// will be combined and returned.
// messages will be appended to all search results. The types of messages that can be appended are both string and
// gollm.Message.
func (a Agent) SearchAndUseTools(ctx context.Context, searchQuery string, questions []string, loops int, allowConcurrent bool, maxReads int, tools []SearchTool, messages ...any) (Knowledge, error) {
var knowledge = Knowledge{
func (a Agent) SearchAndUseTools(ctx context.Context, searchQuery string, questions []string, loops int, allowConcurrent bool, maxReads int, tools []SearchTool, messages ...any) (shared.Knowledge, error) {
var knowledge = shared.Knowledge{
OriginalQuestions: questions,
RemainingQuestions: questions,
}
@@ -184,13 +186,13 @@ Use appropriate tools to analyze the search results and determine if they answer
}
slog.Info("search results called and executed", "error", err, "results text", results.Text, "results", results.CallResults)
var learned []Knowledge
var learned []shared.Knowledge
for _, r := range results.CallResults {
if r.Error != nil {
continue
}
if k, ok := r.Result.(Knowledge); ok {
if k, ok := r.Result.(shared.Knowledge); ok {
learned = append(learned, k)
} else {
slog.Error("result is not knowledge", "result", r.Result)
@@ -210,7 +212,7 @@ Use appropriate tools to analyze the search results and determine if they answer
return knowledge, nil
}
func (a Agent) SearchAndRead(ctx context.Context, searchQuery string, questions []string, allowConcurrent bool, maxReads int) (Knowledge, error) {
func (a Agent) SearchAndRead(ctx context.Context, searchQuery string, questions []string, allowConcurrent bool, maxReads int) (shared.Knowledge, error) {
return a.SearchAndUseTools(ctx, searchQuery, questions, 2, allowConcurrent, maxReads, []SearchTool{
{
Name: "readpage",

View File

@@ -1,4 +1,4 @@
package agents
package shared
import (
"strings"

View File

@@ -5,8 +5,6 @@ import (
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents"
gollm "gitea.stevedudenhoeffer.com/steve/go-llm"
)
@@ -27,7 +25,7 @@ const DefaultPrompt = `Use the provided tools to answer the questions in your cu
// source is the source of the knowledge, for example a URL.
// Any tool call that returns a Knowledge object will be handled by this function in crafting the final Knowledge object.
// Any other return type will be passed to the resultWorker function, if provided.
func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowledge, systemPrompt string, userInput string, source string, history []gollm.Message, resultWorker func(res gollm.ToolCallResponse)) (agents.Knowledge, error) {
func (w KnowledgeWorker) Answer(context context.Context, knowledge *Knowledge, systemPrompt string, userInput string, source string, history []gollm.Message, resultWorker func(res gollm.ToolCallResponse)) (Knowledge, error) {
var req gollm.Request
if systemPrompt != "" {
@@ -80,7 +78,7 @@ func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowl
func(ctx *gollm.Context, args struct {
NotesToSelf []string `description:"Notes to leave for yourself for later."`
}) (any, error) {
return agents.Knowledge{
return Knowledge{
NotesToSelf: args.NotesToSelf,
}, nil
}),
@@ -90,7 +88,7 @@ func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowl
func(ctx *gollm.Context, args struct {
Objectives []string `description:"The objectives to set for executions going forward."`
}) (any, error) {
return agents.Knowledge{
return Knowledge{
CurrentObjectives: args.Objectives,
}, nil
}),
@@ -100,13 +98,13 @@ func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowl
func(ctx *gollm.Context, args struct {
Info []string `description:"The information to learn from the input."`
}) (any, error) {
var k []agents.TidBit
var k []TidBit
for _, i := range args.Info {
k = append(k, agents.TidBit{Info: i, Source: source})
k = append(k, TidBit{Info: i, Source: source})
}
return agents.Knowledge{
return Knowledge{
Knowledge: k,
}, nil
})).
@@ -120,17 +118,17 @@ func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowl
resp, err := w.Model.ChatComplete(context, req)
if err != nil {
return agents.Knowledge{}, fmt.Errorf("error calling model: %w", err)
return Knowledge{}, fmt.Errorf("error calling model: %w", err)
}
if len(resp.Choices) == 0 {
return agents.Knowledge{}, fmt.Errorf("no choices found")
return Knowledge{}, fmt.Errorf("no choices found")
}
choice := resp.Choices[0]
if len(choice.Calls) == 0 {
return agents.Knowledge{}, fmt.Errorf("no calls found")
return Knowledge{}, fmt.Errorf("no calls found")
}
var callNames []string
@@ -141,14 +139,14 @@ func (w KnowledgeWorker) Answer(context context.Context, knowledge *agents.Knowl
results, err := w.ToolBox.ExecuteCallbacks(gollm.NewContext(context, req, &choice, nil), choice.Calls, w.OnNewFunction, w.OnFunctionFinished)
if err != nil {
return agents.Knowledge{}, fmt.Errorf("error executing callbacks: %w", err)
return Knowledge{}, fmt.Errorf("error executing callbacks: %w", err)
}
var res = agents.Knowledge{}
var res = Knowledge{}
for _, r := range results {
switch v := r.Result.(type) {
case agents.Knowledge:
case Knowledge:
res = res.Absorb(v)
default:

View File

@@ -3,25 +3,28 @@ package agents
import (
"context"
"fmt"
"github.com/asticode/go-astisub"
"github.com/lrstanley/go-ytdlp"
"io"
"log/slog"
"net/url"
"os"
"path/filepath"
"github.com/asticode/go-astisub"
"github.com/lrstanley/go-ytdlp"
"gitea.stevedudenhoeffer.com/steve/answer/pkg/agents/shared"
)
func init() {
ytdlp.MustInstall(context.Background(), nil)
}
func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions []string) (Knowledge, error) {
func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions []string) (shared.Knowledge, error) {
dlp := ytdlp.New()
tmpDir, err := os.MkdirTemp("", "mort-ytdlp-")
if err != nil {
return Knowledge{}, fmt.Errorf("error creating temp dir: %w", err)
return shared.Knowledge{}, fmt.Errorf("error creating temp dir: %w", err)
}
slog.Info("created temp dir", "path", tmpDir)
@@ -40,15 +43,15 @@ func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions
res, err := dlp.Run(ctx, u.String())
if err != nil {
return Knowledge{}, fmt.Errorf("error running yt-dlp: %w", err)
return shared.Knowledge{}, fmt.Errorf("error running yt-dlp: %w", err)
}
if res == nil {
return Knowledge{}, fmt.Errorf("yt-dlp returned nil")
return shared.Knowledge{}, fmt.Errorf("yt-dlp returned nil")
}
if res.ExitCode != 0 {
return Knowledge{}, fmt.Errorf("yt-dlp exited with code %d", res.ExitCode)
return shared.Knowledge{}, fmt.Errorf("yt-dlp exited with code %d", res.ExitCode)
}
// the transcript for this video now _should_ be at tmpDir/subs.en.vtt, however if it's not then just fine any
@@ -60,7 +63,7 @@ func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions
vttFile = ""
files, err := os.ReadDir(tmpDir)
if err != nil {
return Knowledge{}, fmt.Errorf("error reading directory: %w", err)
return shared.Knowledge{}, fmt.Errorf("error reading directory: %w", err)
}
for _, file := range files {
@@ -72,7 +75,7 @@ func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions
}
if vttFile == "" {
return Knowledge{}, fmt.Errorf("no vtt file found")
return shared.Knowledge{}, fmt.Errorf("no vtt file found")
}
fp, err := os.Open(vttFile)
@@ -83,16 +86,16 @@ func (a Agent) ReadYouTubeTranscript(ctx context.Context, u *url.URL, questions
}
}(fp)
if err != nil {
return Knowledge{}, fmt.Errorf("error opening vtt file: %w", err)
return shared.Knowledge{}, fmt.Errorf("error opening vtt file: %w", err)
}
subs, err := astisub.ReadFromWebVTT(fp)
if err != nil {
return Knowledge{}, fmt.Errorf("error reading vtt file: %w", err)
return shared.Knowledge{}, fmt.Errorf("error reading vtt file: %w", err)
}
if len(subs.Items) == 0 {
return Knowledge{}, fmt.Errorf("no subtitles found")
return shared.Knowledge{}, fmt.Errorf("no subtitles found")
}
var ts string