Skip to content

Commit 779bcc6

Browse files
authored
app: basic server and client (honganh1206#79)
* app: lifecycle management and store * remove lifecycle struct * Add app binary for future use * Add server, remove store * Add basic server and client * Switch to union for blocks and unified message * Remove app/, update block structs * Fix marshal unmarshal blocks * Fix UT, update README
1 parent 3f1906c commit 779bcc6

File tree

26 files changed

+1087
-458
lines changed

26 files changed

+1087
-458
lines changed

Makefile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ run/new:
22
go run ./main.go
33
run/latest:
44
go run ./main.go -n=false
5+
serve:
6+
go run ./main.go serve
57
list/models:
68
go run ./main.go list
79
list/conversations:

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,26 @@
22

33
Simple CLI-based AI coding agent
44

5+
![Clue](./assets/clue.png)
6+
57
If this proves to be helpful to anyone, consider it my thanks to the open-source community :)
68

79
(Important) Read through this wonderful article on [how to build an agent by Thorsten Ball](https://ampcode.com/how-to-build-an-agent) and follow along if possible
810

911
## Installation
1012

1113
1. Add Anthropic API key as an environment variable with `export ANTHROPIC_API_KEY="your-api-key-here"`
12-
2. Run the installation script for the latest version:
14+
2. Run the installation script for the latest version (Linux only at the moment):
1315

1416
```bash
1517
curl -fsSL https://raw.githubusercontent.com/honganh1206/clue/main/scripts/install.sh | sudo -E bash
1618
```
1719

20+
## Development
21+
22+
```bash
23+
make serve # Run the server
24+
make # Run the agent
25+
```
26+
1827
[References](./docs/References.md)

agent/agent.go

Lines changed: 34 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,35 @@
11
package agent
22

33
import (
4-
"bufio"
54
"context"
6-
"database/sql"
75
"encoding/json"
86
"fmt"
9-
"log"
10-
"os"
117
"strings"
128

13-
"github.com/honganh1206/clue/conversation"
9+
"github.com/honganh1206/clue/api"
1410
"github.com/honganh1206/clue/inference"
15-
"github.com/honganh1206/clue/prompts"
11+
"github.com/honganh1206/clue/message"
12+
"github.com/honganh1206/clue/server/data/conversation"
1613
"github.com/honganh1206/clue/tools"
17-
_ "github.com/mattn/go-sqlite3"
1814
)
1915

20-
func Gen(conversationID string, modelConfig inference.ModelConfig, db *sql.DB) error {
21-
model, err := inference.Init(modelConfig)
22-
if err != nil {
23-
log.Fatalf("Failed to initialize model: %s", err.Error())
24-
}
25-
26-
scanner := bufio.NewScanner(os.Stdin)
27-
getUserMsg := func() (string, bool) {
28-
if !scanner.Scan() {
29-
return "", false
30-
}
31-
return scanner.Text(), true
32-
}
33-
34-
toolDefs := []tools.ToolDefinition{tools.ReadFileDefinition, tools.ListFilesDefinition, tools.EditFileDefinition}
35-
36-
var a *Agent
37-
var conv *conversation.Conversation
38-
39-
if conversationID != "" {
40-
conv, err = conversation.Load(conversationID, db)
41-
if err != nil {
42-
return err
43-
}
44-
} else {
45-
conv, err = conversation.New()
46-
if err != nil {
47-
return err
48-
}
49-
}
50-
a = New(model, getUserMsg, conv, toolDefs, prompts.ClaudeSystemPrompt(), db)
51-
52-
// In production, use Background() as the final root context()
53-
// For dev env, TODO for temporary scaffolding
54-
err = a.run(context.TODO())
55-
56-
if err != nil {
57-
return err
58-
}
59-
60-
return nil
61-
}
62-
6316
type Agent struct {
6417
model inference.Model
6518
getUserMessage func() (string, bool)
6619
tools []tools.ToolDefinition
6720
promptPath string
6821
conversation *conversation.Conversation
69-
// FIXME: CRUD operations should be on its own, not a field in Agent
70-
db *sql.DB
22+
client *api.Client
7123
}
7224

73-
func New(model inference.Model, getUserMsg func() (string, bool), conversation *conversation.Conversation, tools []tools.ToolDefinition, promptPath string, db *sql.DB) *Agent {
25+
func New(model inference.Model, getUserMsg func() (string, bool), conversation *conversation.Conversation, tools []tools.ToolDefinition, promptPath string, client *api.Client) *Agent {
7426
return &Agent{
7527
model: model,
7628
getUserMessage: getUserMsg,
7729
tools: tools,
7830
promptPath: promptPath,
7931
conversation: conversation,
80-
db: db,
32+
client: client,
8133
}
8234
}
8335

@@ -100,7 +52,7 @@ func getModelColor(modelName string) string {
10052
}
10153
}
10254

103-
func (a *Agent) run(ctx context.Context) error {
55+
func (a *Agent) Run(ctx context.Context) error {
10456
modelName := a.model.Name()
10557
colorCode := getModelColor(modelName)
10658
resetCode := "\u001b[0m"
@@ -111,40 +63,35 @@ func (a *Agent) run(ctx context.Context) error {
11163

11264
for {
11365
if readUserInput {
114-
11566
fmt.Print("\u001b[94m>\u001b[0m ")
11667
userInput, ok := a.getUserMessage()
11768
if !ok {
11869
break
11970
}
12071

121-
userMsg := conversation.MessageRequest{
122-
MessageParam: conversation.MessageParam{
123-
Role: conversation.UserRole,
124-
Content: []conversation.ContentBlock{conversation.NewTextContentBlock(userInput)},
125-
},
72+
userMsg := &message.Message{
73+
Role: message.UserRole,
74+
Content: []message.ContentBlockUnion{message.NewTextContentBlock(userInput)},
12675
}
127-
a.conversation.Append(userMsg.MessageParam)
76+
77+
a.conversation.Append(userMsg)
12878
a.saveConversation()
12979
}
13080

131-
// TODO: Update with something interactive
132-
// fmt.Printf("\u001b[93m%s\u001b[0m: ", modelName)
133-
134-
agentMsg, err := a.model.RunInference(ctx, a.conversation.Messages, a.tools)
81+
agentMsg, err := a.model.CompleteStream(ctx, a.conversation.Messages, a.tools)
13582
if err != nil {
13683
return err
13784
}
13885

139-
a.conversation.Append(agentMsg.MessageParam)
86+
a.conversation.Append(agentMsg)
14087
a.saveConversation()
14188

142-
toolResults := []conversation.ContentBlock{}
89+
toolResults := []message.ContentBlockUnion{}
14390

144-
for _, content := range agentMsg.Content {
145-
switch c := content.(type) {
146-
case conversation.ToolUseContentBlock:
147-
result := a.executeTool(c.ID, c.Name, c.Input)
91+
for _, c := range agentMsg.Content {
92+
switch c.Type {
93+
case message.ToolUseType:
94+
result := a.executeTool(c.OfToolUseBlock.ID, c.OfToolUseBlock.Name, c.OfToolUseBlock.Input)
14895
toolResults = append(toolResults, result)
14996
}
15097
}
@@ -156,24 +103,21 @@ func (a *Agent) run(ctx context.Context) error {
156103

157104
readUserInput = false
158105

159-
toolResultMsg := conversation.MessageRequest{
160-
MessageParam: conversation.MessageParam{
161-
Role: conversation.UserRole,
162-
Content: toolResults,
163-
},
106+
toolResultMsg := &message.Message{
107+
Role: message.UserRole,
108+
Content: toolResults,
164109
}
165110

166-
a.conversation.Append(toolResultMsg.MessageParam)
111+
a.conversation.Append(toolResultMsg)
167112
a.saveConversation()
168113
}
169114

170115
return nil
171116
}
172117

173-
func (a *Agent) executeTool(id, name string, input json.RawMessage) conversation.ContentBlock {
118+
func (a *Agent) executeTool(id, name string, input json.RawMessage) message.ContentBlockUnion {
174119
var toolDef tools.ToolDefinition
175120
var found bool
176-
177121
for _, tool := range a.tools {
178122
if tool.Name == name {
179123
toolDef = tool
@@ -185,29 +129,29 @@ func (a *Agent) executeTool(id, name string, input json.RawMessage) conversation
185129
if !found {
186130
// TODO: Return proper error type
187131
errorMsg := "tool not found"
188-
return conversation.NewToolResultContentBlock(id, errorMsg, true)
132+
return message.NewToolResultContentBlock(id, errorMsg, true)
189133
}
190134

191135
fmt.Printf("\u001b[92mtool\u001b[0m: %s(%s)\n", name, input)
136+
println()
192137

193138
response, err := toolDef.Function(input)
194139

195140
if err != nil {
196-
return conversation.NewToolResultContentBlock(id, err.Error(), true)
141+
return message.NewToolResultContentBlock(id, err.Error(), true)
197142
}
198143

199-
return conversation.NewToolResultContentBlock(id, response, true)
144+
return message.NewToolResultContentBlock(id, response, false)
200145
}
201146

202147
func (a *Agent) saveConversation() error {
203-
// FIXME: Very drafty. Consider moving the db field out of Agent struct?
204-
err := a.conversation.SaveTo(a.db)
205-
if err != nil {
206-
// 4. Log any errors from history.Save to os.Stderr and return the error.
207-
fmt.Fprintf(os.Stderr, "Warning: could not save conversation to DB: %v\n", err)
208-
return err
148+
if len(a.conversation.Messages) > 0 {
149+
err := a.client.SaveConversation(a.conversation)
150+
if err != nil {
151+
fmt.Printf("DEBUG: Failed conversation details - ConversationID: %s\n", a.conversation.ID)
152+
return err
153+
}
209154
}
210155

211156
return nil
212-
213157
}

api/client.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
"github.com/honganh1206/clue/message"
11+
"github.com/honganh1206/clue/server/data/conversation"
12+
)
13+
14+
type Client struct {
15+
baseURL string
16+
httpClient *http.Client
17+
}
18+
19+
func NewClient(baseURL string) *Client {
20+
if baseURL == "" {
21+
baseURL = "http://localhost:11435"
22+
}
23+
return &Client{
24+
baseURL: baseURL,
25+
httpClient: &http.Client{},
26+
}
27+
}
28+
29+
func (c *Client) CreateConversation() (*conversation.Conversation, error) {
30+
resp, err := c.httpClient.Post(c.baseURL+"/conversations", "application/json", nil)
31+
if err != nil {
32+
return nil, fmt.Errorf("failed to create conversation: %w", err)
33+
}
34+
defer resp.Body.Close()
35+
36+
if resp.StatusCode != http.StatusOK {
37+
body, _ := io.ReadAll(resp.Body)
38+
return nil, fmt.Errorf("server error: %s", string(body))
39+
}
40+
41+
var result map[string]string
42+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
43+
return nil, fmt.Errorf("failed to decode response: %w", err)
44+
}
45+
46+
// TODO: Use NewConversation method?
47+
return &conversation.Conversation{
48+
ID: result["id"],
49+
Messages: make([]*message.Message, 0),
50+
}, nil
51+
}
52+
53+
func (c *Client) ListConversations() ([]conversation.ConversationMetadata, error) {
54+
resp, err := c.httpClient.Get(c.baseURL + "/conversations")
55+
if err != nil {
56+
return nil, fmt.Errorf("failed to list conversations: %w", err)
57+
}
58+
defer resp.Body.Close()
59+
60+
if resp.StatusCode != http.StatusOK {
61+
body, _ := io.ReadAll(resp.Body)
62+
return nil, fmt.Errorf("server error: %s", string(body))
63+
}
64+
65+
var conversations []conversation.ConversationMetadata
66+
if err := json.NewDecoder(resp.Body).Decode(&conversations); err != nil {
67+
return nil, fmt.Errorf("failed to decode response: %w", err)
68+
}
69+
70+
return conversations, nil
71+
}
72+
73+
func (c *Client) GetConversation(id string) (*conversation.Conversation, error) {
74+
resp, err := c.httpClient.Get(c.baseURL + "/conversations/" + id)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to get conversation: %w", err)
77+
}
78+
defer resp.Body.Close()
79+
80+
if resp.StatusCode == http.StatusNotFound {
81+
return nil, conversation.ErrConversationNotFound
82+
}
83+
84+
if resp.StatusCode != http.StatusOK {
85+
body, _ := io.ReadAll(resp.Body)
86+
return nil, fmt.Errorf("server error: %s", string(body))
87+
}
88+
89+
var conv conversation.Conversation
90+
if err := json.NewDecoder(resp.Body).Decode(&conv); err != nil {
91+
return nil, fmt.Errorf("failed to decode response: %w", err)
92+
}
93+
94+
return &conv, nil
95+
}
96+
97+
func (c *Client) SaveConversation(conv *conversation.Conversation) error {
98+
jsonData, err := json.Marshal(conv)
99+
if err != nil {
100+
return fmt.Errorf("failed to marshal conversation: %w", err)
101+
}
102+
103+
url := fmt.Sprintf("%s/conversations/%s", c.baseURL, conv.ID)
104+
req, err := http.NewRequest(http.MethodPut, url, bytes.NewBuffer(jsonData))
105+
if err != nil {
106+
return fmt.Errorf("failed to create request: %w", err)
107+
}
108+
req.Header.Set("Content-Type", "application/json")
109+
110+
resp, err := c.httpClient.Do(req)
111+
if err != nil {
112+
return fmt.Errorf("failed to save conversation: %w", err)
113+
}
114+
defer resp.Body.Close()
115+
116+
if resp.StatusCode == http.StatusNotFound {
117+
return conversation.ErrConversationNotFound
118+
}
119+
120+
if resp.StatusCode != http.StatusOK {
121+
body, _ := io.ReadAll(resp.Body)
122+
return fmt.Errorf("server error: %s", string(body))
123+
}
124+
125+
return nil
126+
}
127+
128+
func (c *Client) GetLatestConversationID() (string, error) {
129+
conversations, err := c.ListConversations()
130+
if err != nil {
131+
return "", err
132+
}
133+
134+
if len(conversations) == 0 {
135+
return "", conversation.ErrConversationNotFound
136+
}
137+
138+
return conversations[0].ID, nil
139+
}

assets/clue.png

114 KB
Loading

0 commit comments

Comments
 (0)