324 lines
7.2 KiB
Go
324 lines
7.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"agentu/internal/llm"
|
|
"agentu/internal/tools"
|
|
)
|
|
|
|
const defaultMaxToolIterations = 8
|
|
|
|
type Agent struct {
|
|
provider llm.Provider
|
|
providerName string
|
|
model string
|
|
thinking string
|
|
thinkingParam string
|
|
thinkingEnabled bool
|
|
systemPrompt string
|
|
toolRegistry *tools.Registry
|
|
toolTimeout time.Duration
|
|
maxToolIterations int
|
|
messages []llm.Message
|
|
}
|
|
|
|
type Options struct {
|
|
Provider llm.Provider
|
|
ProviderName string
|
|
Model string
|
|
Thinking string
|
|
ThinkingParam string
|
|
ThinkingEnabled bool
|
|
SystemPrompt string
|
|
ToolRegistry *tools.Registry
|
|
ToolTimeout time.Duration
|
|
MaxToolIterations int
|
|
}
|
|
|
|
func New(opts Options) *Agent {
|
|
maxToolIterations := opts.MaxToolIterations
|
|
if maxToolIterations <= 0 {
|
|
maxToolIterations = defaultMaxToolIterations
|
|
}
|
|
toolTimeout := opts.ToolTimeout
|
|
if toolTimeout <= 0 {
|
|
toolTimeout = 30 * time.Second
|
|
}
|
|
a := &Agent{
|
|
provider: opts.Provider,
|
|
providerName: opts.ProviderName,
|
|
model: opts.Model,
|
|
thinking: opts.Thinking,
|
|
thinkingParam: opts.ThinkingParam,
|
|
thinkingEnabled: opts.ThinkingEnabled,
|
|
systemPrompt: opts.SystemPrompt,
|
|
toolRegistry: opts.ToolRegistry,
|
|
toolTimeout: toolTimeout,
|
|
maxToolIterations: maxToolIterations,
|
|
}
|
|
a.Clear()
|
|
return a
|
|
}
|
|
|
|
type RuntimeOptions struct {
|
|
Provider llm.Provider
|
|
ProviderName string
|
|
Model string
|
|
Thinking string
|
|
ThinkingParam string
|
|
ThinkingEnabled bool
|
|
}
|
|
|
|
type RuntimeInfo struct {
|
|
ProviderName string
|
|
Model string
|
|
Thinking string
|
|
ThinkingEnabled bool
|
|
}
|
|
|
|
func (a *Agent) SetRuntime(opts RuntimeOptions) {
|
|
if opts.Provider != nil {
|
|
a.provider = opts.Provider
|
|
}
|
|
if opts.ProviderName != "" {
|
|
a.providerName = opts.ProviderName
|
|
}
|
|
if opts.Model != "" {
|
|
a.model = opts.Model
|
|
}
|
|
a.thinking = opts.Thinking
|
|
a.thinkingParam = opts.ThinkingParam
|
|
a.thinkingEnabled = opts.ThinkingEnabled
|
|
}
|
|
|
|
func (a *Agent) RuntimeInfo() RuntimeInfo {
|
|
return RuntimeInfo{
|
|
ProviderName: a.providerName,
|
|
Model: a.model,
|
|
Thinking: a.thinking,
|
|
ThinkingEnabled: a.thinkingEnabled,
|
|
}
|
|
}
|
|
|
|
func (a *Agent) Clear() {
|
|
a.messages = nil
|
|
if strings.TrimSpace(a.systemPrompt) != "" {
|
|
a.messages = append(a.messages, llm.Message{
|
|
Role: llm.RoleSystem,
|
|
Content: a.systemPrompt,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Messages returns a copy of the current conversation messages.
|
|
func (a *Agent) Messages() []llm.Message {
|
|
out := make([]llm.Message, len(a.messages))
|
|
copy(out, a.messages)
|
|
return out
|
|
}
|
|
|
|
// SetMessages replaces the conversation messages (used for session restore).
|
|
func (a *Agent) SetMessages(messages []llm.Message) {
|
|
a.messages = append([]llm.Message(nil), messages...)
|
|
}
|
|
|
|
func (a *Agent) RunTurn(ctx context.Context, input string, out io.Writer, logs io.Writer) error {
|
|
input = strings.TrimSpace(input)
|
|
if input == "" {
|
|
return nil
|
|
}
|
|
a.messages = append(a.messages, llm.Message{Role: llm.RoleUser, Content: input})
|
|
|
|
for iteration := 0; iteration <= a.maxToolIterations; iteration++ {
|
|
content, calls, err := a.streamAssistant(ctx, out)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(calls) == 0 {
|
|
a.messages = append(a.messages, llm.Message{
|
|
Role: llm.RoleAssistant,
|
|
Content: content,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
a.messages = append(a.messages, llm.Message{
|
|
Role: llm.RoleAssistant,
|
|
Content: content,
|
|
ToolCalls: calls,
|
|
})
|
|
if content != "" {
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
for _, call := range calls {
|
|
result := a.executeTool(ctx, call, logs)
|
|
a.messages = append(a.messages, llm.Message{
|
|
Role: llm.RoleTool,
|
|
ToolCallID: call.ID,
|
|
Content: result,
|
|
})
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("tool iteration limit reached: %d", a.maxToolIterations)
|
|
}
|
|
|
|
func (a *Agent) streamAssistant(ctx context.Context, out io.Writer) (string, []llm.ToolCall, error) {
|
|
var content strings.Builder
|
|
calls := make(map[int]*toolCallBuilder)
|
|
|
|
req := llm.ChatRequest{
|
|
Model: a.model,
|
|
Messages: append([]llm.Message(nil), a.messages...),
|
|
Tools: a.toolDefinitions(),
|
|
}
|
|
if a.thinkingEnabled {
|
|
thinkingParam := a.thinkingParam
|
|
if thinkingParam == "" {
|
|
thinkingParam = "thinking"
|
|
}
|
|
req.Extra = map[string]any{thinkingParam: a.thinking}
|
|
}
|
|
if len(req.Tools) > 0 {
|
|
req.ToolChoice = "auto"
|
|
}
|
|
|
|
err := a.provider.ChatStream(ctx, req, func(event llm.StreamEvent) error {
|
|
if event.Content != "" {
|
|
content.WriteString(event.Content)
|
|
if out != nil {
|
|
if _, err := io.WriteString(out, event.Content); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, delta := range event.ToolCalls {
|
|
builder := calls[delta.Index]
|
|
if builder == nil {
|
|
builder = &toolCallBuilder{index: delta.Index}
|
|
calls[delta.Index] = builder
|
|
}
|
|
builder.apply(delta)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
return content.String(), finalizeToolCalls(calls), nil
|
|
}
|
|
|
|
func (a *Agent) toolDefinitions() []llm.Tool {
|
|
if a.toolRegistry == nil {
|
|
return nil
|
|
}
|
|
return a.toolRegistry.Definitions()
|
|
}
|
|
|
|
func (a *Agent) executeTool(ctx context.Context, call llm.ToolCall, logs io.Writer) string {
|
|
name := call.Function.Name
|
|
if logs != nil {
|
|
fmt.Fprintf(logs, "\n[tool] %s %s\n", name, compact(call.Function.Arguments, 400))
|
|
}
|
|
|
|
if a.toolRegistry == nil {
|
|
return tools.FormatError(fmt.Errorf("tool registry is disabled"))
|
|
}
|
|
tool, ok := a.toolRegistry.Get(name)
|
|
if !ok {
|
|
return tools.FormatError(fmt.Errorf("unknown tool: %s", name))
|
|
}
|
|
|
|
args := strings.TrimSpace(call.Function.Arguments)
|
|
if args == "" {
|
|
args = "{}"
|
|
}
|
|
|
|
toolCtx, cancel := context.WithTimeout(ctx, a.toolTimeout)
|
|
defer cancel()
|
|
output, err := tool.Execute(toolCtx, json.RawMessage(args))
|
|
if err != nil {
|
|
if output != "" {
|
|
return output + "\n" + tools.FormatError(err)
|
|
}
|
|
return tools.FormatError(err)
|
|
}
|
|
if logs != nil {
|
|
fmt.Fprintf(logs, "[tool] %s done\n", name)
|
|
}
|
|
return output
|
|
}
|
|
|
|
type toolCallBuilder struct {
|
|
index int
|
|
id string
|
|
callType string
|
|
name string
|
|
arguments strings.Builder
|
|
}
|
|
|
|
func (b *toolCallBuilder) apply(delta llm.ToolCallDelta) {
|
|
if delta.ID != "" {
|
|
b.id = delta.ID
|
|
}
|
|
if delta.Type != "" {
|
|
b.callType = delta.Type
|
|
}
|
|
if delta.Name != "" {
|
|
b.name = delta.Name
|
|
}
|
|
if delta.Arguments != "" {
|
|
b.arguments.WriteString(delta.Arguments)
|
|
}
|
|
}
|
|
|
|
func finalizeToolCalls(builders map[int]*toolCallBuilder) []llm.ToolCall {
|
|
if len(builders) == 0 {
|
|
return nil
|
|
}
|
|
indexes := make([]int, 0, len(builders))
|
|
for index := range builders {
|
|
indexes = append(indexes, index)
|
|
}
|
|
sort.Ints(indexes)
|
|
|
|
calls := make([]llm.ToolCall, 0, len(indexes))
|
|
for _, index := range indexes {
|
|
builder := builders[index]
|
|
id := builder.id
|
|
if id == "" {
|
|
id = fmt.Sprintf("call_%d", index)
|
|
}
|
|
callType := builder.callType
|
|
if callType == "" {
|
|
callType = "function"
|
|
}
|
|
calls = append(calls, llm.ToolCall{
|
|
ID: id,
|
|
Type: callType,
|
|
Function: llm.FunctionCall{
|
|
Name: builder.name,
|
|
Arguments: builder.arguments.String(),
|
|
},
|
|
})
|
|
}
|
|
return calls
|
|
}
|
|
|
|
func compact(value string, limit int) string {
|
|
value = strings.TrimSpace(value)
|
|
if len(value) <= limit {
|
|
return value
|
|
}
|
|
return value[:limit] + "...[truncated]"
|
|
}
|