Skip to content

Commit 7a9d140

Browse files
author
Marek Safarik
committed
Add generic LLM provider interface and agent-side tool queue
Signed-off-by: Marek Safarik <msafarik@redhat.com>
1 parent 8541467 commit 7a9d140

File tree

5 files changed

+179
-137
lines changed

5 files changed

+179
-137
lines changed

cmd/client/main.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,6 @@ func main() {
3636
log.Printf("Warning: .env file not loaded: %v", err)
3737
}
3838

39-
apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
40-
if apiKey == "" {
41-
log.Println("ANTHROPIC_API_KEY environment variable not set")
42-
return
43-
}
44-
4539
serverPath := os.Getenv("MCP_SERVER_PATH")
4640
if serverPath == "" {
4741
serverPath = defaultServerPath
@@ -52,16 +46,22 @@ func main() {
5246
port = defaultPort
5347
}
5448

49+
apiKey := strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY"))
50+
if apiKey == "" {
51+
log.Println("ANTHROPIC_API_KEY environment variable not set")
52+
return
53+
}
54+
5555
if _, err := os.Stat(serverPath); os.IsNotExist(err) { // #nosec G703 -- serverPath from env/default, not user input
5656
log.Printf("Warning: MCP server not found at %s", serverPath)
5757
log.Printf("Build the server first: go build -o bin/server cmd/server/main.go")
5858
return
5959
}
6060

61+
provider := agent.NewClaudeProvider(apiKey)
6162
agentInstance := agent.NewAgent(agent.Config{
62-
APIKey: apiKey,
6363
ServerPath: serverPath,
64-
})
64+
}, provider)
6565

6666
if err := agentInstance.Connect(ctx); err != nil {
6767
log.Printf("Failed to connect to MCP server: %v", err)

internal/agent/agent.go

Lines changed: 52 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ type Agent struct {
4444
mcpCmd *exec.Cmd
4545
tools []*mcp.Tool
4646
messages []Message
47+
toolQueue []ToolRequest
4748
}
4849

4950
func NewAgent(cfg Config, provider LLMProvider) *Agent {
50-
// Apply defaults
5151
if cfg.Model == "" {
5252
cfg.Model = DefaultModel
5353
}
@@ -107,7 +107,7 @@ func (a *Agent) Close() {
107107
}
108108

109109
func (a *Agent) SendMessage(ctx context.Context, userMessage string, onMessage func(Message)) error {
110-
a.messages = append(a.messages, Message{Role: RoleUser, Content: []Content{{Type: ContentTypeText, Text: userMessage}}})
110+
a.messages = append(a.messages, Message{Role: RoleUser, Text: userMessage})
111111
return a.callLLM(ctx, onMessage)
112112
}
113113

@@ -119,29 +119,27 @@ func (a *Agent) callLLM(ctx context.Context, onMessage func(Message)) error {
119119
Messages: a.messages,
120120
Tools: a.tools,
121121
})
122-
123122
if err != nil {
124123
return err
125124
}
126-
for _, textBlock := range response.TextBlocks {
127-
a.messages = append(a.messages, Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: textBlock}}})
128-
onMessage(Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: textBlock}}})
129-
}
130-
for _, toolUse := range response.ToolUses {
131-
msg := Message{
132-
Role: RoleTool,
133-
Content: []Content{
134-
{
135-
Type: ContentTypeToolUse,
136-
ToolID: toolUse.ID,
137-
ToolName: toolUse.Name,
138-
ToolInput: toolUse.Arguments,
139-
},
140-
},
141-
}
142125

143-
a.messages = append(a.messages, msg)
144-
onMessage(msg)
126+
msg := Message{
127+
Role: RoleAssistant,
128+
Text: strings.Join(response.TextBlocks, "\n"),
129+
ToolCalls: response.ToolUses,
130+
}
131+
a.messages = append(a.messages, msg)
132+
133+
if msg.Text != "" {
134+
onMessage(Message{Role: RoleAssistant, Text: msg.Text})
135+
}
136+
137+
a.toolQueue = make([]ToolRequest, len(response.ToolUses))
138+
copy(a.toolQueue, response.ToolUses)
139+
140+
if len(a.toolQueue) > 0 {
141+
tc := a.toolQueue[0]
142+
onMessage(Message{Role: RoleAssistant, ToolCalls: []ToolRequest{tc}})
145143
}
146144

147145
return nil
@@ -166,20 +164,45 @@ func (a *Agent) ExecuteTool(ctx context.Context, toolRequest *ToolRequest, onMes
166164
default:
167165
resultText = extractTextContent(result.Content)
168166
}
167+
169168
msg := Message{
170169
Role: RoleTool,
171-
Content: []Content{
172-
{
173-
Type: ContentTypeToolResult,
174-
Text: resultText,
175-
IsError: isError,
176-
},
170+
ToolResult: &ToolResult{
171+
ToolID: toolRequest.ID,
172+
Output: resultText,
173+
IsError: isError,
177174
},
178175
}
179176
a.messages = append(a.messages, msg)
180177
onMessage(msg)
181178

182-
return nil
179+
return a.advanceToolQueue(ctx, onMessage)
180+
}
181+
182+
func (a *Agent) ToolDeny(ctx context.Context, tool *ToolRequest, onMessage func(Message)) error {
183+
a.messages = append(a.messages, Message{
184+
Role: RoleTool,
185+
ToolResult: &ToolResult{
186+
ToolID: tool.ID,
187+
Output: "Tool execution denied by user.",
188+
},
189+
})
190+
191+
return a.advanceToolQueue(ctx, onMessage)
192+
}
193+
194+
func (a *Agent) advanceToolQueue(ctx context.Context, onMessage func(Message)) error {
195+
if len(a.toolQueue) > 0 {
196+
a.toolQueue = a.toolQueue[1:]
197+
}
198+
199+
if len(a.toolQueue) > 0 {
200+
tc := a.toolQueue[0]
201+
onMessage(Message{Role: RoleAssistant, ToolCalls: []ToolRequest{tc}})
202+
return nil
203+
}
204+
205+
return a.callLLM(ctx, onMessage)
183206
}
184207

185208
func extractTextContent(content []mcp.Content) string {
@@ -194,19 +217,7 @@ func extractTextContent(content []mcp.Content) string {
194217
return resultText.String()
195218
}
196219

197-
func (a *Agent) ToolDeny(ctx context.Context, tool *ToolRequest, onMessage func(Message)) error {
198-
a.messages = append(a.messages, Message{
199-
Role: RoleTool,
200-
Content: []Content{
201-
{
202-
Type: ContentTypeToolResult,
203-
Text: "Tool execution denied by user.",
204-
},
205-
},
206-
})
207-
return a.callLLM(ctx, onMessage)
208-
}
209-
210220
func (a *Agent) Reset() {
211221
a.messages = []Message{}
222+
a.toolQueue = nil
212223
}

internal/agent/provider_claude.go

Lines changed: 86 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,91 @@
11
package agent
22

33
import (
4+
"context"
5+
"fmt"
6+
47
"github.com/anthropics/anthropic-sdk-go"
8+
"github.com/anthropics/anthropic-sdk-go/option"
59
"github.com/modelcontextprotocol/go-sdk/mcp"
610
)
711

8-
func convertMCPToolToClaudeTool(tool *mcp.Tool) anthropic.ToolUnionParam {
12+
type ClaudeProvider struct {
13+
client anthropic.Client
14+
}
15+
16+
func NewClaudeProvider(apiKey string) *ClaudeProvider {
17+
return &ClaudeProvider{
18+
client: anthropic.NewClient(option.WithAPIKey(apiKey)),
19+
}
20+
}
21+
22+
func (p *ClaudeProvider) Chat(ctx context.Context, opts ChatOptions) (*LLMResponse, error) {
23+
messages := convertMessagesToClaude(opts.Messages)
24+
tools := convertToolsToClaude(opts.Tools)
25+
26+
response, err := p.client.Messages.New(ctx, anthropic.MessageNewParams{
27+
Model: anthropic.Model(opts.Model),
28+
MaxTokens: opts.MaxTokens,
29+
System: []anthropic.TextBlockParam{{Type: "text", Text: opts.SystemPrompt}},
30+
Messages: messages,
31+
Tools: tools,
32+
})
33+
if err != nil {
34+
return nil, fmt.Errorf("claude API error: %w", err)
35+
}
36+
37+
return parseClaudeResponse(response), nil
38+
}
39+
40+
func convertMessagesToClaude(messages []Message) []anthropic.MessageParam {
41+
result := make([]anthropic.MessageParam, 0, len(messages))
42+
43+
i := 0
44+
for i < len(messages) {
45+
msg := messages[i]
46+
47+
switch msg.Role {
48+
case RoleUser:
49+
result = append(result, anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Text)))
50+
i++
51+
52+
case RoleAssistant:
53+
var blocks []anthropic.ContentBlockParamUnion
54+
if msg.Text != "" {
55+
blocks = append(blocks, anthropic.NewTextBlock(msg.Text))
56+
}
57+
for _, tc := range msg.ToolCalls {
58+
blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
59+
}
60+
result = append(result, anthropic.NewAssistantMessage(blocks...))
61+
i++
62+
63+
case RoleTool:
64+
var blocks []anthropic.ContentBlockParamUnion
65+
for i < len(messages) && messages[i].Role == RoleTool && messages[i].ToolResult != nil {
66+
tr := messages[i].ToolResult
67+
blocks = append(blocks, anthropic.NewToolResultBlock(tr.ToolID, tr.Output, tr.IsError))
68+
i++
69+
}
70+
result = append(result, anthropic.NewUserMessage(blocks...))
71+
72+
default:
73+
i++
74+
}
75+
}
76+
77+
return result
78+
}
79+
80+
func convertToolsToClaude(tools []*mcp.Tool) []anthropic.ToolUnionParam {
81+
result := make([]anthropic.ToolUnionParam, 0, len(tools))
82+
for _, tool := range tools {
83+
result = append(result, convertMCPToolToClaude(tool))
84+
}
85+
return result
86+
}
87+
88+
func convertMCPToolToClaude(tool *mcp.Tool) anthropic.ToolUnionParam {
989
inputSchemaMap, ok := tool.InputSchema.(map[string]any)
1090
if !ok || inputSchemaMap == nil {
1191
inputSchemaMap = map[string]any{}
@@ -40,55 +120,21 @@ func convertMCPToolToClaudeTool(tool *mcp.Tool) anthropic.ToolUnionParam {
40120
return toolParam
41121
}
42122

43-
// func (a *Agent) callClaude(ctx context.Context, onMessage func(Message)) error {
44-
// response, err := a.anthropicClient.Messages.New(ctx, anthropic.MessageNewParams{
45-
// Model: a.config.Model,
46-
// MaxTokens: a.config.MaxTokens,
47-
// System: []anthropic.TextBlockParam{{Type: "text", Text: a.config.SystemPrompt}},
48-
// Messages: a.messages,
49-
// Tools: a.tools,
50-
// })
51-
// if err != nil {
52-
// return fmt.Errorf("claude API error: %w", err)
53-
// }
54-
55-
// assistantContent, toolRequests := a.processResponse(response, onMessage)
56-
57-
// a.messages = append(a.messages, anthropic.NewAssistantMessage(assistantContent...))
58-
59-
// for _, toolRequest := range toolRequests {
60-
// onMessage(Message{
61-
// Role: "tool_request",
62-
// Content: toolRequest.Name,
63-
// ToolID: toolRequest.ID,
64-
// Tool: toolRequest,
65-
// })
66-
// }
67-
// return nil
68-
// }
69-
70-
func (a *Agent) processResponse(response *anthropic.Message, onMessage func(Message)) (
71-
[]anthropic.ContentBlockParamUnion,
72-
[]*ToolRequest,
73-
) {
74-
var assistantContent []anthropic.ContentBlockParamUnion
75-
var toolRequests []*ToolRequest
123+
func parseClaudeResponse(response *anthropic.Message) *LLMResponse {
124+
result := &LLMResponse{}
76125

77126
for _, block := range response.Content {
78127
switch content := block.AsAny().(type) {
79128
case anthropic.TextBlock:
80-
onMessage(Message{Role: "assistant", Content: content.Text})
81-
assistantContent = append(assistantContent, anthropic.NewTextBlock(content.Text))
82-
129+
result.TextBlocks = append(result.TextBlocks, content.Text)
83130
case anthropic.ToolUseBlock:
84-
assistantContent = append(assistantContent, anthropic.NewToolUseBlock(content.ID, content.Input, content.Name))
85-
toolRequests = append(toolRequests, &ToolRequest{
131+
result.ToolUses = append(result.ToolUses, ToolRequest{
86132
ID: content.ID,
87133
Name: content.Name,
88134
Arguments: content.Input,
89135
})
90136
}
91137
}
92138

93-
return assistantContent, toolRequests
139+
return result
94140
}

internal/agent/types.go

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,32 +8,21 @@ const (
88
RoleTool Role = "tool"
99
)
1010

11-
type ContentType string
12-
13-
const (
14-
ContentTypeText ContentType = "text"
15-
ContentTypeToolUse ContentType = "tool_use"
16-
ContentTypeToolResult ContentType = "tool_result"
17-
)
18-
1911
type Message struct {
20-
Role Role `json:"role"`
21-
Content []Content `json:"content"`
22-
}
23-
24-
type Content struct {
25-
Type ContentType `json:"type"`
26-
Text string `json:"text,omitempty"`
27-
28-
ToolID string `json:"tool_id,omitempty"`
29-
ToolName string `json:"tool_name,omitempty"`
30-
ToolInput any `json:"tool_input,omitempty"`
31-
32-
IsError bool `json:"is_error,omitempty"`
12+
Role Role
13+
Text string
14+
ToolCalls []ToolRequest
15+
ToolResult *ToolResult
3316
}
3417

3518
type ToolRequest struct {
3619
ID string
3720
Name string
3821
Arguments any
3922
}
23+
24+
type ToolResult struct {
25+
ToolID string
26+
Output string
27+
IsError bool
28+
}

0 commit comments

Comments
 (0)