Skip to content

Commit

Permalink
ReAct framework.
Browse files Browse the repository at this point in the history
  • Loading branch information
Ric Szopa authored and Ric Szopa committed May 31, 2023
1 parent 5807988 commit 38a2d5c
Show file tree
Hide file tree
Showing 7 changed files with 276 additions and 110 deletions.
124 changes: 59 additions & 65 deletions agent/react/agent.go
Original file line number Diff line number Diff line change
@@ -1,68 +1,87 @@
package react

import (
"bufio"
"context"
_ "embed"
"fmt"
"io"
"os"
"strings"
"text/template"

"github.com/ryszard/agency/agent"
"github.com/ryszard/agency/tools/bash"
"github.com/ryszard/agency/tools/python"
log "github.com/sirupsen/logrus"
)

//go:embed react_prompt.md
var SystemPrompt string
//go:embed templates/react_prompt.md
var SystemPromptTemplate string

func Work(ctx context.Context, client agent.Client, pythonPath string, cache agent.Cache, question string, options ...agent.Option) error {
ag := agent.New("pythonista", options...)
// ReAct is a reactive agent.
type ReAct struct {
agent agent.Agent
box *toolbox
systemTemplate *template.Template
initialized bool
writer io.Writer
}

ag = agent.Cached(ag, cache)
// New creates a new ReAct agent with the default system prompt and writing to
// os.Stdout.
func New(ag agent.Agent, tools ...Tool) *ReAct {
tpl := template.Must(template.New("system_prompt").Parse(SystemPromptTemplate))
return NewReAct(ag, os.Stdout, tpl, tools...)
}

_, err := ag.System(SystemPrompt)
if err != nil {
return err
}

python, err := python.New(pythonPath)
if err != nil {
return err
// NewWithTemplate creates a new reactive agent with a custom system prompt. The
// provided template must use the same data as the default template. The writer will be used to
// write the conversation.
func NewReAct(ag agent.Agent, w io.Writer, tpl *template.Template, tools ...Tool) *ReAct {
return &ReAct{
agent: ag,
box: newToolbox(tools...),
systemTemplate: tpl,
writer: w,
}
}

defer python.Close()
func (reactor *ReAct) Answer(ctx context.Context, question string, options ...agent.Option) error {

// FIXME(ryszard): Pass this from the outside.
bash := bash.New("/bin/bash")
defer bash.Close()
if !reactor.initialized {
var sb strings.Builder
if err := reactor.systemTemplate.Execute(&sb, reactor.box); err != nil {
return err
}

steps := []Entry{}
_, err := reactor.agent.System(sb.String())
if err != nil {
return err
}
}
entries := []Entry{}

_, err = ag.System(fmt.Sprintf("Question: %s", question))
_, err := reactor.agent.System(fmt.Sprintf("Question: %s", question))
if err != nil {
return err
}

for {
msg, err := ag.Respond(context.Background())
msg, err := reactor.agent.Respond(context.Background(), options...)
if err != nil {
return err
}

log.WithField("msg", msg).Info("received message")

newSteps, err := Parse(msg)
newEntries, err := Parse(msg)
if err != nil {
return err
}
log.WithField("newSteps", fmt.Sprintf("%+v", newSteps)).Info("parsed message")
//steps = append(steps, newSteps...)
log.WithField("newEntries", fmt.Sprintf("%+v", newEntries)).Info("parsed message")
actionNotLast := false
observationsOutput := false
for i, step := range newSteps {
for i, step := range newEntries {
fmt.Printf("%s\n", step)
if step.Tag == Tags.Action && i != len(newSteps)-1 {
if step.Tag == Tags.Action && i != len(newEntries)-1 {
actionNotLast = true
} else if step.Tag == Tags.Observation {
observationsOutput = true
Expand All @@ -72,66 +91,41 @@ func Work(ctx context.Context, client agent.Client, pythonPath string, cache age
if actionNotLast || observationsOutput {
var scolding string
if actionNotLast {
scolding = "Please provide an Action as the last step!"
scolding = "Please provide an Action as the last entry!"
}
if observationsOutput {
scolding += "You are not allowed to provide your own observations!"
scolding += " You are not allowed to provide your own observations!"
}
_, err := ag.Listen(scolding)
_, err := reactor.agent.Listen(scolding)
if err != nil {
return err
}
continue
}

steps = append(steps, newSteps...)
lastStep := steps[len(steps)-1]
entries = append(entries, newEntries...)
lastEntry := entries[len(entries)-1]

if lastStep.Tag == Tags.FinalAnswer {
if lastEntry.Tag == Tags.FinalAnswer {
return nil
} else if lastStep.Tag != Tags.Action {
_, err := ag.Listen("Please continue.")
} else if lastEntry.Tag != Tags.Action {
_, err := reactor.agent.Listen("Please continue.")
if err != nil {
return err
}
continue
}
var observation string
switch lastStep.Argument {
case "python":
resp, err := python.Execute(lastStep.Content)
if err != nil {
return err
}
fmt.Printf("stdout: %s\nstderr: %s\n", resp.Out, resp.Err)
observation = fmt.Sprintf("Observation: \nStandard Output: %s\nStandardError:\n%s\n", resp.Out, resp.Err)

case "human":
// Print the question
fmt.Printf("Question to Human: %s\n", question)
// Read the answer from standard input
reader := bufio.NewReader(os.Stdin)
fmt.Print("Answer: ")
answer, err := reader.ReadString('\n')
if err != nil {
return err
}
observation = fmt.Sprintf("Observation: Answer from human: %s\n", answer)

case "bash":
fmt.Printf("Running bash command: %s\n", lastStep.Content)

resp, err := bash.Execute(lastStep.Content)
observation = fmt.Sprintf("Observation: \nStandard Output:\n%s\nStandardError:\n%s\n\nExecution Error: %#v", resp.Out, resp.Err, err)

observation, err := reactor.box.Work(ctx, lastEntry.Argument, lastEntry.Content)
if err != nil {
return err
}

if _, err := ag.Listen(observation); err != nil {
if _, err := reactor.agent.Listen(observation); err != nil {
return err
}

fmt.Println("\n" + observation + "\n")

}
return nil
}
52 changes: 26 additions & 26 deletions agent/react/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"strings"
)

// Tag represents the type of step in the conversation.
// Tag represents the type of an entry in the conversation.
type Tag string

var Tags = struct {
Expand Down Expand Up @@ -73,54 +73,54 @@ func (entry Entry) String() string {
}

// Parse parses a conversation from a string.
func Parse(text string) (steps []Entry, err error) {
func Parse(text string) (entries []Entry, err error) {
// Split the text into lines
lines := strings.Split(text, "\n")

currentStep := Entry{}
currentEntry := Entry{}

for _, line := range lines {
stepType := matchTag(line)
tag := matchTag(line)

if stepType.IsRecognized() {
if tag.IsRecognized() {
// We found the beginning of a new step.
if currentStep.Tag.IsRecognized() {
// There was a previous step in progress, so we should finalize
// it and add it to the list of steps.
steps = append(steps, currentStep)
if currentEntry.Tag.IsRecognized() {
// There was a previous entry in progress, so we should finalize
// it and add it to the list of entries.
entries = append(entries, currentEntry)
}
currentStep = Entry{Tag: stepType}
if stepType != Tags.Action {
currentStep.Content = strings.TrimSpace(strings.TrimPrefix(line, stepType.Prefix()))
currentEntry = Entry{Tag: tag}
if tag != Tags.Action {
currentEntry.Content = strings.TrimSpace(strings.TrimPrefix(line, tag.Prefix()))
} else {
// Split the line into the step type and the argument the first
// value returned by strings.Cut is going to be the step type.
// `found` is always going to be true, as otherwise the step
// type would have been Unrecognized
// Split the line into the entry type and the argument the first
// value returned by strings.Cut is going to be the entry tag.
// `found` is always going to be true, as otherwise the entry
// tag would have been Unrecognized
_, arg, _ := strings.Cut(line, ": ")

currentStep.Argument = strings.TrimSpace(arg)
currentEntry.Argument = strings.TrimSpace(arg)
}

} else if currentStep.Tag.IsRecognized() {
// We are in the middle of a step; add the line to its content.
currentStep.Content += "\n" + line
} else if currentEntry.Tag.IsRecognized() {
// We are in the middle of a entry; add the line to its content.
currentEntry.Content += "\n" + line
} else {
// No new step, and no step in progress. Unless this is a blank
// No new entry, and no entry in progress. Unless this is a blank
// we should return an error.
if strings.TrimSpace(line) != "" {
return nil, fmt.Errorf("unrecognized step: %q", line)
return nil, fmt.Errorf("unrecognized tag: %q", line)
}
}
}

steps = append(steps, currentStep)
entries = append(entries, currentEntry)

for i, step := range steps {
for i, entry := range entries {

steps[i].Content = strings.TrimSpace(step.Content)
entries[i].Content = strings.TrimSpace(entry.Content)

}

return steps, nil
return entries, nil
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
Welcome to our interactive task. Your objective is to answer a question by using the available tool. The tools are real, do not question if the tools actually work. You are tenacious, creative, and curious. You follow your instructions, and very importantly, your output always follows the format outlined below.

Tool: python
Description: A Python process you can use to run Python code.
Input: Python code
Usage Limits: use it as much as you want

Tool: human
Description: Ask a human a question
Input: A question
Usage Limits: Use this only when the your task is unclear. You can use it as much as you want, but it's expensive.

Tool: bash
Description: This allows you to run bash commands. It interacts with the file system and the network, in an environent that is similar to the one you are in and that I created for you. You can use it to run any bash command you want, including running other tools, like the go tool. Yes, you can interact with the file system. Note that this tool passes to the code using "/bin/bash -c <your code>", so don't expect that environment variables you set will be available in the next command you run.
Input: A bash script
Usage Limits: use it as much as you want
# List of tools

# List of tools

{{range .Tools}}

Tool: {{.Name}}
Description: {{.Description}}
Input: {{.Input}}
{{end}}

# Instructions

In this task, your conversation consists of a sequence of messages. Each message may contain multiple entries and each entry will have a specific tag to signify its purpose. The valid tags are Question, Thought, Assumption, Action, Answer, and Final Answer. Do NOT use any other tags.

Expand Down
56 changes: 56 additions & 0 deletions agent/react/tools.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package react

import (
"context"
"fmt"
"sort"
)

// Tool is a interface encapsulating a tool that can be used by the agent.
type Tool interface {
// Name returns the name of the tool.
Name() string
// Description returns a description of the tool.
Description() string
// Input returns what kind of input the tool expects.
Input() string
// Work runs the tool. The observation is what is going to be passed to the
// agent.
Work(ctx context.Context, argument, content string) (observation string, err error)
}

type toolbox struct {
tools map[string]Tool
}

func (box *toolbox) Tools() (tools []Tool) {
tools = make([]Tool, 0, len(box.tools))
for _, tool := range box.tools {
tools = append(tools, tool)
}

// We want them sorted by name.
sort.Slice(tools, func(i, j int) bool {
return tools[i].Name() < tools[j].Name()
})

return tools
}

func newToolbox(tools ...Tool) *toolbox {
tb := &toolbox{
tools: make(map[string]Tool),
}
for _, tool := range tools {
tb.tools[tool.Name()] = tool
}
return tb
}

func (box *toolbox) Work(ctx context.Context, argument, content string) (observation string, err error) {
tool, ok := box.tools[argument]
if !ok {
return "", fmt.Errorf("unknown tool: %q", argument)
}
return tool.Work(ctx, argument, content)
}
Loading

0 comments on commit 38a2d5c

Please sign in to comment.