Skip to content

Commit

Permalink
feat(auto): renders a set of actions and collect the responses.
Browse files Browse the repository at this point in the history
  • Loading branch information
tartavull committed Jun 19, 2023
1 parent a3b3b8a commit 6d4cd99
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 143 deletions.
105 changes: 58 additions & 47 deletions mods/auto/auto.go
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
package auto


import (
"fmt"
"strings"

tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/cursor"

"github.com/charmbracelet/mods/common"
"github.com/charmbracelet/mods/sandbox"
)

type State int

const (
stateAnswering State = iota
stateCompleted
)

type Auto struct {
Response *LLMResponse
textarea textarea.Model
styles common.Styles
textarea textarea.Model
state State
Response *LLMResponse
answers []string
outputs []sandbox.Result
}

func New(s common.Styles) *Auto {
Expand All @@ -32,9 +42,13 @@ func New(s common.Styles) *Auto {
{
"context": "Some context",
"goal": "Some goal",
"actions": [{"question":"Some question"}]
"questions": ["Some question"],
"commands": ["cat plan.md", "another action"]
}`
a.Response, _ = a.ParseResponse(testJson)
a.state = stateAnswering
a.answers = []string{}
a.outputs = []sandbox.Result{}

return a
}
Expand All @@ -48,21 +62,33 @@ func (a *Auto) SetSize(width, height int) {
a.textarea.MaxHeight = height / 2
}


func (a *Auto) Update(msg tea.Msg) (*Auto, tea.Cmd) {
var cmd tea.Cmd
cmds := make([]tea.Cmd, 0)

a.textarea, cmd = a.textarea.Update(msg)
cmds = append(cmds, cmd)

switch msg := msg.(type) {
if a.state == stateAnswering {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+d" {
a.textarea.Reset()
//FIXME actually submit an answer
if len(a.outputs) < len(a.Response.Commands) {
if msg.String() == "y" {
for _, cmd := range a.Response.Commands {
a.outputs = append(a.outputs, sandbox.ExecuteCommand(cmd))
}
}
} else if len(a.answers) < len(a.Response.Questions) {
a.textarea, cmd = a.textarea.Update(msg)
cmds = append(cmds, cmd)
if msg.String() == "ctrl+d" {
a.answers = append(a.answers, a.textarea.Value())
a.textarea.Reset()
}
} else {
a.state = stateCompleted
}
case cursor.BlinkMsg:
cmds = append(cmds, textarea.Blink)
cmds = append(cmds, textarea.Blink)
}
}
return a, tea.Batch(cmds...)
}
Expand All @@ -74,41 +100,26 @@ func (a *Auto) View() string {
view += "\n\n"
view += a.styles.GoalTag.String() + " " + a.styles.Goal.Render(a.Response.Goal)
view += "\n\n"
view += a.styles.QuestionTag.String() + " " + a.styles.Question.Render(a.Response.Actions[0].String())
view += "\n\n"
view += a.textarea.View()
view += "\n"
view += a.styles.Comment.Render("press ctrl+d to submit answer")
return a.styles.App.Render(view)
}

func (a *Auto) AddPrompt(prompt string) string {
// FIXME
return fmt.Sprintf(InitialPrompt, prompt)
}



const InitialPrompt = `
{
"prompt": "You are part of a program that helps the user to build complex projects. You are able to carry two type of actions: to ask the user clarifying questions such that you don't need to make any assumptions, and to execute commands on the user's terminal.
Your response should be structured as JSON and include the fields context, goal and actions. See a description of each field below:
'context': use this field to summarize in a paragraph the result of the previous actions. In other words, you use this field to summarize your current understanding of the situation.
'goal': given the current context, you must summarize the desire goal that you want to achieve next.
'actions': this is a list of the type { 'question': "How are you?" } or { 'cmd': 'tree ./ ' }. As exampled, you can either ask questions to the user or run commands in the terminal. You are not allowed to access files outside of the current directory neither directly with your commands or indirectly, for example, by a program you write and execute. When you actions include writting code, there is a strong preference to use Golang.
Here is a simple example of your response
{ 'context': 'The user wants to understand how long has the current machine being on',
'goal': 'I will use the program uptime to get extract such information',
'actions': [{'cmd': 'uptime'},{'question':'is there a particular format in which you want the time to be shown?'}]
if a.state == stateAnswering {
if len(a.outputs) < len(a.Response.Commands) {
var styledCmds []string
for _, cmd := range a.Response.Commands {
styledCmds = append(styledCmds, fmt.Sprintf("$ %s", cmd))
}
styledCmdStr := strings.Join(styledCmds, "\n")
view += a.styles.Command.Render(styledCmdStr) + "\n\n"
view += a.styles.Comment.Render("Press y to execute all commands or esc to exit")
} else if len(a.answers) < len(a.Response.Questions) {
view += a.styles.QuestionTag.String() + " " + a.styles.Question.Render(a.Response.Questions[len(a.answers)])
view += "\n\n"
view += a.textarea.View()
view += "\n"
view += a.styles.Comment.Render(fmt.Sprintf("Question %d of %d: press ctrl+d to submit answer", len(a.answers)+1, len(a.Response.Questions)))
}
} else if a.state == stateCompleted {
view += fmt.Sprintf("%+v\n", a.answers)
view += fmt.Sprintf("%+v\n", a.outputs)
}
"

In the current directory is a git repository of the code that parses, executes your responses and feeds them back to you.
There is a file called plan.md which describes how to make this tool smarter and more useful. Your goal is to help us perfect this plan and also to carry out the plan.
%s
"previous": [],
return a.styles.App.Render(view)
}
`
55 changes: 23 additions & 32 deletions mods/auto/auto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,109 +6,100 @@ import (
"github.com/charmbracelet/lipgloss"
)

func TestAddPrompt(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
msg := "Hello, world"
result := a.AddPrompt(msg)
if len(result) <= len(msg) {
t.Errorf("Expected '%s' to be longer than '%s'", result, msg)
}
}

func TestParseResponse(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `{
"context": "Some context",
"goal": "Some goal",
"actions": [{"question":"Some question"}]
"questions": ["Some question"],
"commands": ["Some cmd"]
}`
_, err := a.ParseResponse(testJson)
if err != nil {
t.Errorf("Error parsing JSON: %v", err)
}
}

func TestParseCmd(t *testing.T) {
func TestParseMultipleQuestionsAndCommands(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `
{
"context": "Some context",
"goal": "Some goal",
"actions": [{"cmd":"Some cmd"}]
"questions": ["Some question", "Another question"],
"commands": ["Some cmd", "Another cmd"]
}`
_, err := a.ParseResponse(testJson)
if err != nil {
t.Errorf("Error parsing JSON: %v", err)
}
}

func TestParseQuestion(t *testing.T) {
func TestParseInvalidQuestionType(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `
{
"context": "Some context",
"goal": "Some goal",
"actions": [{"question":"Some question"}]
"questions": [1]
}`
_, err := a.ParseResponse(testJson)
if err != nil {
t.Errorf("Error parsing JSON: %v", err)
if err == nil || err.Error() != "json: cannot unmarshal number into Go struct field LLMResponse.questions of type string" {
t.Errorf("Expected error due to invalid question type but got: %v", err)
}
}

func TestParseMissingField(t *testing.T) {
func TestParseInvalidCommandType(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `
{
"context": "Some context",
"actions": [{"question":"Some question"}]
"goal": "Some goal",
"commands": [1]
}`
_, err := a.ParseResponse(testJson)
if err == nil || err.Error() != "missing field 'goal' in JSON" {
t.Errorf("Expected error due to missing field but got: %v", err)
if err == nil || err.Error() != "json: cannot unmarshal number into Go struct field LLMResponse.commands of type string" {
t.Errorf("Expected error due to invalid command type but got: %v", err)
}
}

func TestParseExtraField(t *testing.T) {
func TestParseMissingField(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `
{
"context": "Some context",
"goal": "Some goal",
"actions": [{"question":"Some question"}],
"responses": "Some response"
"questions": ["Some question"]
}`
_, err := a.ParseResponse(testJson)
if err == nil || err.Error() != "extra field 'responses' in JSON" {
t.Errorf("Expected error due to extra field but got: %v", err)
if err == nil || err.Error() != "missing field 'goal' in JSON" {
t.Errorf("Expected error due to missing field but got: %v", err)
}
}

func TestParseInvalidAction(t *testing.T) {
func TestParseExtraField(t *testing.T) {
r := lipgloss.DefaultRenderer()
s := common.MakeStyles(r)
a := New(s)
testJson := `
{
"context": "Some context",
"goal": "Some goal",
"actions": [{"invalid":"Some invalid action"}]
"questions": ["Some question"],
"responses": "Some response"
}`
_, err := a.ParseResponse(testJson)
if err == nil || err.Error() != "actions must contain either a valid 'question' or 'cmd' object" {
t.Errorf("Expected error due to invalid action but got: %v", err)
if err == nil || err.Error() != "extra field 'responses' in JSON" {
t.Errorf("Expected error due to extra field but got: %v", err)
}
}

66 changes: 5 additions & 61 deletions mods/auto/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,67 +7,10 @@ import (
)

type LLMResponse struct {
Context string `json:"context"`
Goal string `json:"goal"`
Actions []Action `json:"actions"`
}

type Action interface {
IsValid() bool
String() string
}

type Question struct {
Question string `json:"question"`
}

type Cmd struct {
Cmd string `json:"cmd"`
}

func (q Question) IsValid() bool {
return q.Question != ""
}

func (q Question) String() string {
return q.Question
}

func (c Cmd) IsValid() bool {
return c.Cmd != ""
}

func (c Cmd) String() string {
return c.Cmd
}

func (c *LLMResponse) UnmarshalJSON(data []byte) error {
type Alias LLMResponse
aux := &struct {
Actions []json.RawMessage `json:"actions"`
*Alias
}{
Alias: (*Alias)(c),
}

if err := json.Unmarshal(data, &aux); err != nil {
return err
}

for _, action := range aux.Actions {
var q Question
if err := json.Unmarshal(action, &q); err == nil && q.IsValid() {
c.Actions = append(c.Actions, q)
continue
}
var cmd Cmd
if err := json.Unmarshal(action, &cmd); err == nil && cmd.IsValid() {
c.Actions = append(c.Actions, cmd)
continue
}
return fmt.Errorf("actions must contain either a valid 'question' or 'cmd' object")
}
return nil
Context string `json:"context"`
Goal string `json:"goal"`
Questions []string `json:"questions"`
Commands []string `json:"commands"`
}

func (a *Auto) ParseResponse(jsonStr string) (*LLMResponse, error) {
Expand Down Expand Up @@ -115,3 +58,4 @@ func structHasField(structType reflect.Type, jsonFieldName string) bool {
}
return false
}

4 changes: 4 additions & 0 deletions mods/common/styles.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type Styles struct {
GoalTag lipgloss.Style
Question lipgloss.Style
QuestionTag lipgloss.Style

Command lipgloss.Style
}

func MakeStyles(r *lipgloss.Renderer) (s Styles) {
Expand All @@ -52,5 +54,7 @@ func MakeStyles(r *lipgloss.Renderer) (s Styles) {

s.Question = s.Quote.Copy()
s.QuestionTag = s.Question.Copy().Bold(true).SetString("Question:")

s.Command = r.NewStyle().Foreground(lipgloss.Color("#F1F1F1")).Background(lipgloss.Color("#000000")).Padding(1, 1)
return s
}
3 changes: 0 additions & 3 deletions mods/mods.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,6 @@ func (m *Mods) startCompletionCmd(content string) tea.Cmd {
if prefix != "" {
content = strings.TrimSpace(prefix + "\n\n" + content)
}
if cfg.Auto {
content = m.auto.AddPrompt(content)
}
if !cfg.NoLimit {
if len(content) > mod.MaxChars {
content = content[:mod.MaxChars]
Expand Down
Loading

0 comments on commit 6d4cd99

Please sign in to comment.