From a06cfd995f05782854844e51a71a656f70274f64 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Thu, 18 Dec 2025 11:53:07 +0300 Subject: Feat: add agent entity --- agent/agent.go | 60 ++++++++++++++++++++++++++++ agent/format.go | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 agent/agent.go create mode 100644 agent/format.go (limited to 'agent') diff --git a/agent/agent.go b/agent/agent.go new file mode 100644 index 0000000..30e30e3 --- /dev/null +++ b/agent/agent.go @@ -0,0 +1,60 @@ +package agent + +// Agent defines an interface for processing tool outputs. +// An Agent can clean, summarize, or otherwise transform raw tool outputs +// before they are presented to the main LLM. +type Agent interface { + // Process takes the original tool arguments and the raw output from the tool, + // and returns a cleaned/summarized version suitable for the main LLM context. + Process(args map[string]string, rawOutput []byte) []byte +} + +// registry holds mapping from tool names to agents. +var registry = make(map[string]Agent) + +// Register adds an agent for a specific tool name. +// If an agent already exists for the tool, it will be replaced. +func Register(toolName string, a Agent) { + registry[toolName] = a +} + +// Get returns the agent for a tool name, or nil if none is registered. +func Get(toolName string) Agent { + return registry[toolName] +} + +// FormatterAgent is a simple agent that applies formatting functions. +type FormatterAgent struct { + formatFunc func([]byte) (string, error) +} + +// NewFormatterAgent creates a FormatterAgent that uses the given formatting function. +func NewFormatterAgent(formatFunc func([]byte) (string, error)) *FormatterAgent { + return &FormatterAgent{formatFunc: formatFunc} +} + +// Process applies the formatting function to raw output. +func (a *FormatterAgent) Process(args map[string]string, rawOutput []byte) []byte { + if a.formatFunc == nil { + return rawOutput + } + formatted, err := a.formatFunc(rawOutput) + if err != nil { + // On error, return raw output with a warning prefix + return []byte("[formatting failed, showing raw output]\n" + string(rawOutput)) + } + return []byte(formatted) +} + +// DefaultFormatter returns a FormatterAgent that uses the appropriate formatting +// based on tool name. +func DefaultFormatter(toolName string) Agent { + switch toolName { + case "websearch": + return NewFormatterAgent(FormatSearchResults) + case "read_url": + return NewFormatterAgent(FormatWebPageContent) + default: + return nil + } +} \ No newline at end of file diff --git a/agent/format.go b/agent/format.go new file mode 100644 index 0000000..01ecb07 --- /dev/null +++ b/agent/format.go @@ -0,0 +1,120 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" +) + +// FormatSearchResults takes raw JSON from websearch and returns a concise summary. +func FormatSearchResults(rawJSON []byte) (string, error) { + // Try to unmarshal as generic slice of maps + var results []map[string]interface{} + if err := json.Unmarshal(rawJSON, &results); err != nil { + // If that fails, try as a single map (maybe wrapper object) + var wrapper map[string]interface{} + if err2 := json.Unmarshal(rawJSON, &wrapper); err2 == nil { + // Look for a "results" or "data" field + if data, ok := wrapper["results"].([]interface{}); ok { + // Convert to slice of maps + for _, item := range data { + if m, ok := item.(map[string]interface{}); ok { + results = append(results, m) + } + } + } else if data, ok := wrapper["data"].([]interface{}); ok { + for _, item := range data { + if m, ok := item.(map[string]interface{}); ok { + results = append(results, m) + } + } + } else { + // No slice found, treat wrapper as single result + results = []map[string]interface{}{wrapper} + } + } else { + return "", fmt.Errorf("failed to unmarshal search results: %v (also %v)", err, err2) + } + } + + if len(results) == 0 { + return "No search results found.", nil + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Found %d results:\n", len(results))) + for i, r := range results { + // Extract common fields + title := getString(r, "title", "Title", "name", "heading") + snippet := getString(r, "snippet", "description", "content", "body", "text", "summary") + url := getString(r, "url", "link", "uri", "source") + + sb.WriteString(fmt.Sprintf("%d. ", i+1)) + if title != "" { + sb.WriteString(fmt.Sprintf("**%s**", title)) + } else { + sb.WriteString("(No title)") + } + if snippet != "" { + // Truncate snippet to reasonable length + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + sb.WriteString(fmt.Sprintf(" — %s", snippet)) + } + if url != "" { + sb.WriteString(fmt.Sprintf(" (%s)", url)) + } + sb.WriteString("\n") + } + return sb.String(), nil +} + +// FormatWebPageContent takes raw JSON from read_url and returns a concise summary. +func FormatWebPageContent(rawJSON []byte) (string, error) { + // Try to unmarshal as generic map + var data map[string]interface{} + if err := json.Unmarshal(rawJSON, &data); err != nil { + // If that fails, try as string directly + var content string + if err2 := json.Unmarshal(rawJSON, &content); err2 == nil { + return truncateText(content, 500), nil + } + // Both failed, return first error + return "", fmt.Errorf("failed to unmarshal web page content: %v", err) + } + + // Look for common content fields + content := getString(data, "content", "text", "body", "article", "html", "markdown", "data") + if content == "" { + // If no content field, marshal the whole thing as a short string + summary := fmt.Sprintf("%v", data) + return truncateText(summary, 300), nil + } + return truncateText(content, 500), nil +} + +// Helper to get a string value from a map, trying multiple keys. +func getString(m map[string]interface{}, keys ...string) string { + for _, k := range keys { + if val, ok := m[k]; ok { + switch v := val.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return fmt.Sprintf("%v", v) + } + } + } + return "" +} + +// Helper to truncate text and add ellipsis. +func truncateText(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} \ No newline at end of file -- cgit v1.2.3 From 8cdec5e54455c3dfb74c2e8016f17f806f86fa54 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Thu, 18 Dec 2025 14:39:06 +0300 Subject: Feat: http request for agent --- agent/request.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 agent/request.go (limited to 'agent') diff --git a/agent/request.go b/agent/request.go new file mode 100644 index 0000000..3b8d083 --- /dev/null +++ b/agent/request.go @@ -0,0 +1,42 @@ +package agent + +import ( + "gf-lt/config" + "io" + "log/slog" + "net/http" +) + +var httpClient = &http.Client{} + +type AgentClient struct { + cfg *config.Config + getToken func() string + log slog.Logger +} + +func NewAgentClient(cfg *config.Config, log slog.Logger, gt func() string) *AgentClient { + return &AgentClient{ + cfg: cfg, + getToken: gt, + log: log, + } +} + +func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) { + req, err := http.NewRequest("POST", ag.cfg.CurrentAPI, body) + if err != nil { + return nil, err + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Authorization", "Bearer "+ag.getToken()) + req.Header.Set("Accept-Encoding", "gzip") + resp, err := httpClient.Do(req) + if err != nil { + ag.log.Error("llamacpp api", "error", err) + return nil, err + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} -- cgit v1.2.3 From 67ea1aef0dafb9dc6f82e009cc1ecc613f71e520 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 19 Dec 2025 11:06:22 +0300 Subject: Feat: two agent types; WebAgentB impl --- agent/agent.go | 71 +++++++++++--------------------- agent/format.go | 120 ------------------------------------------------------ agent/request.go | 22 ++++++++++ agent/webagent.go | 34 ++++++++++++++++ 4 files changed, 79 insertions(+), 168 deletions(-) delete mode 100644 agent/format.go create mode 100644 agent/webagent.go (limited to 'agent') diff --git a/agent/agent.go b/agent/agent.go index 30e30e3..5ad1ef1 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -1,60 +1,35 @@ package agent -// Agent defines an interface for processing tool outputs. -// An Agent can clean, summarize, or otherwise transform raw tool outputs -// before they are presented to the main LLM. -type Agent interface { - // Process takes the original tool arguments and the raw output from the tool, - // and returns a cleaned/summarized version suitable for the main LLM context. - Process(args map[string]string, rawOutput []byte) []byte -} +// I see two types of agents possible: +// ones who do their own tools calls +// ones that works only with the output -// registry holds mapping from tool names to agents. -var registry = make(map[string]Agent) +// A: main chat -> agent (handles everything: tool + processing) +// B: main chat -> tool -> agent (process tool output) -// Register adds an agent for a specific tool name. -// If an agent already exists for the tool, it will be replaced. -func Register(toolName string, a Agent) { - registry[toolName] = a +// AgenterA gets a task "find out weather in london" +// proceeds to make tool calls on its own +type AgenterA interface { + ProcessTask(task string) []byte } -// Get returns the agent for a tool name, or nil if none is registered. -func Get(toolName string) Agent { - return registry[toolName] +// AgenterB defines an interface for processing tool outputs +type AgenterB interface { + // Process takes the original tool arguments and the raw output from the tool, + // and returns a cleaned/summarized version suitable for the main LLM context + Process(args map[string]string, rawOutput []byte) []byte } -// FormatterAgent is a simple agent that applies formatting functions. -type FormatterAgent struct { - formatFunc func([]byte) (string, error) -} +// registry holds mapping from tool names to agents +var RegistryB = make(map[string]AgenterB) +var RegistryA = make(map[AgenterA][]string) -// NewFormatterAgent creates a FormatterAgent that uses the given formatting function. -func NewFormatterAgent(formatFunc func([]byte) (string, error)) *FormatterAgent { - return &FormatterAgent{formatFunc: formatFunc} +// Register adds an agent for a specific tool name +// If an agent already exists for the tool, it will be replaced +func RegisterB(toolName string, a AgenterB) { + RegistryB[toolName] = a } -// Process applies the formatting function to raw output. -func (a *FormatterAgent) Process(args map[string]string, rawOutput []byte) []byte { - if a.formatFunc == nil { - return rawOutput - } - formatted, err := a.formatFunc(rawOutput) - if err != nil { - // On error, return raw output with a warning prefix - return []byte("[formatting failed, showing raw output]\n" + string(rawOutput)) - } - return []byte(formatted) +func RegisterA(toolNames []string, a AgenterA) { + RegistryA[a] = toolNames } - -// DefaultFormatter returns a FormatterAgent that uses the appropriate formatting -// based on tool name. -func DefaultFormatter(toolName string) Agent { - switch toolName { - case "websearch": - return NewFormatterAgent(FormatSearchResults) - case "read_url": - return NewFormatterAgent(FormatWebPageContent) - default: - return nil - } -} \ No newline at end of file diff --git a/agent/format.go b/agent/format.go deleted file mode 100644 index 01ecb07..0000000 --- a/agent/format.go +++ /dev/null @@ -1,120 +0,0 @@ -package agent - -import ( - "encoding/json" - "fmt" - "strings" -) - -// FormatSearchResults takes raw JSON from websearch and returns a concise summary. -func FormatSearchResults(rawJSON []byte) (string, error) { - // Try to unmarshal as generic slice of maps - var results []map[string]interface{} - if err := json.Unmarshal(rawJSON, &results); err != nil { - // If that fails, try as a single map (maybe wrapper object) - var wrapper map[string]interface{} - if err2 := json.Unmarshal(rawJSON, &wrapper); err2 == nil { - // Look for a "results" or "data" field - if data, ok := wrapper["results"].([]interface{}); ok { - // Convert to slice of maps - for _, item := range data { - if m, ok := item.(map[string]interface{}); ok { - results = append(results, m) - } - } - } else if data, ok := wrapper["data"].([]interface{}); ok { - for _, item := range data { - if m, ok := item.(map[string]interface{}); ok { - results = append(results, m) - } - } - } else { - // No slice found, treat wrapper as single result - results = []map[string]interface{}{wrapper} - } - } else { - return "", fmt.Errorf("failed to unmarshal search results: %v (also %v)", err, err2) - } - } - - if len(results) == 0 { - return "No search results found.", nil - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Found %d results:\n", len(results))) - for i, r := range results { - // Extract common fields - title := getString(r, "title", "Title", "name", "heading") - snippet := getString(r, "snippet", "description", "content", "body", "text", "summary") - url := getString(r, "url", "link", "uri", "source") - - sb.WriteString(fmt.Sprintf("%d. ", i+1)) - if title != "" { - sb.WriteString(fmt.Sprintf("**%s**", title)) - } else { - sb.WriteString("(No title)") - } - if snippet != "" { - // Truncate snippet to reasonable length - if len(snippet) > 200 { - snippet = snippet[:200] + "..." - } - sb.WriteString(fmt.Sprintf(" — %s", snippet)) - } - if url != "" { - sb.WriteString(fmt.Sprintf(" (%s)", url)) - } - sb.WriteString("\n") - } - return sb.String(), nil -} - -// FormatWebPageContent takes raw JSON from read_url and returns a concise summary. -func FormatWebPageContent(rawJSON []byte) (string, error) { - // Try to unmarshal as generic map - var data map[string]interface{} - if err := json.Unmarshal(rawJSON, &data); err != nil { - // If that fails, try as string directly - var content string - if err2 := json.Unmarshal(rawJSON, &content); err2 == nil { - return truncateText(content, 500), nil - } - // Both failed, return first error - return "", fmt.Errorf("failed to unmarshal web page content: %v", err) - } - - // Look for common content fields - content := getString(data, "content", "text", "body", "article", "html", "markdown", "data") - if content == "" { - // If no content field, marshal the whole thing as a short string - summary := fmt.Sprintf("%v", data) - return truncateText(summary, 300), nil - } - return truncateText(content, 500), nil -} - -// Helper to get a string value from a map, trying multiple keys. -func getString(m map[string]interface{}, keys ...string) string { - for _, k := range keys { - if val, ok := m[k]; ok { - switch v := val.(type) { - case string: - return v - case fmt.Stringer: - return v.String() - default: - return fmt.Sprintf("%v", v) - } - } - } - return "" -} - -// Helper to truncate text and add ellipsis. -func truncateText(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} \ No newline at end of file diff --git a/agent/request.go b/agent/request.go index 3b8d083..e10f03f 100644 --- a/agent/request.go +++ b/agent/request.go @@ -1,7 +1,10 @@ package agent import ( + "bytes" + "encoding/json" "gf-lt/config" + "gf-lt/models" "io" "log/slog" "net/http" @@ -23,9 +26,28 @@ func NewAgentClient(cfg *config.Config, log slog.Logger, gt func() string) *Agen } } +func (ag *AgentClient) FormMsg(sysprompt, msg string) (io.Reader, error) { + agentConvo := []models.RoleMsg{ + {Role: "system", Content: sysprompt}, + {Role: "user", Content: msg}, + } + agentChat := &models.ChatBody{ + Model: ag.cfg.CurrentModel, + Stream: true, + Messages: agentConvo, + } + b, err := json.Marshal(agentChat) + if err != nil { + ag.log.Error("failed to form agent msg", "error", err) + return nil, err + } + return bytes.NewReader(b), nil +} + func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) { req, err := http.NewRequest("POST", ag.cfg.CurrentAPI, body) if err != nil { + ag.log.Error("llamacpp api", "error", err) return nil, err } req.Header.Add("Accept", "application/json") diff --git a/agent/webagent.go b/agent/webagent.go new file mode 100644 index 0000000..0087e8e --- /dev/null +++ b/agent/webagent.go @@ -0,0 +1,34 @@ +package agent + +import ( + "fmt" + "log/slog" +) + +// WebAgentB is a simple agent that applies formatting functions +type WebAgentB struct { + *AgentClient + sysprompt string + log slog.Logger +} + +// NewWebAgentB creates a WebAgentB that uses the given formatting function +func NewWebAgentB(sysprompt string) *WebAgentB { + return &WebAgentB{sysprompt: sysprompt} +} + +// Process applies the formatting function to raw output +func (a *WebAgentB) Process(args map[string]string, rawOutput []byte) []byte { + msg, err := a.FormMsg(a.sysprompt, + fmt.Sprintf("request:\n%+v\ntool response:\n%v", args, string(rawOutput))) + if err != nil { + a.log.Error("failed to process the request", "error", err) + return []byte("failed to process the request; err: " + err.Error()) + } + resp, err := a.LLMRequest(msg) + if err != nil { + a.log.Error("failed to process the request", "error", err) + return []byte("failed to process the request; err: " + err.Error()) + } + return resp +} -- cgit v1.2.3 From a875abcf198dd2f85c518f8bf2c599db66d3e69f Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 19 Dec 2025 12:46:22 +0300 Subject: Enha: agentclient log --- agent/request.go | 4 ++++ agent/webagent.go | 10 ++++------ 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'agent') diff --git a/agent/request.go b/agent/request.go index e10f03f..2d557ac 100644 --- a/agent/request.go +++ b/agent/request.go @@ -26,6 +26,10 @@ func NewAgentClient(cfg *config.Config, log slog.Logger, gt func() string) *Agen } } +func (ag *AgentClient) Log() *slog.Logger { + return &ag.log +} + func (ag *AgentClient) FormMsg(sysprompt, msg string) (io.Reader, error) { agentConvo := []models.RoleMsg{ {Role: "system", Content: sysprompt}, diff --git a/agent/webagent.go b/agent/webagent.go index 0087e8e..ff6cd86 100644 --- a/agent/webagent.go +++ b/agent/webagent.go @@ -2,19 +2,17 @@ package agent import ( "fmt" - "log/slog" ) // WebAgentB is a simple agent that applies formatting functions type WebAgentB struct { *AgentClient sysprompt string - log slog.Logger } // NewWebAgentB creates a WebAgentB that uses the given formatting function -func NewWebAgentB(sysprompt string) *WebAgentB { - return &WebAgentB{sysprompt: sysprompt} +func NewWebAgentB(client *AgentClient, sysprompt string) *WebAgentB { + return &WebAgentB{AgentClient: client, sysprompt: sysprompt} } // Process applies the formatting function to raw output @@ -22,12 +20,12 @@ func (a *WebAgentB) Process(args map[string]string, rawOutput []byte) []byte { msg, err := a.FormMsg(a.sysprompt, fmt.Sprintf("request:\n%+v\ntool response:\n%v", args, string(rawOutput))) if err != nil { - a.log.Error("failed to process the request", "error", err) + a.Log().Error("failed to process the request", "error", err) return []byte("failed to process the request; err: " + err.Error()) } resp, err := a.LLMRequest(msg) if err != nil { - a.log.Error("failed to process the request", "error", err) + a.Log().Error("failed to process the request", "error", err) return []byte("failed to process the request; err: " + err.Error()) } return resp -- cgit v1.2.3 From f779f039745f97f08f25967214d07716ce213326 Mon Sep 17 00:00:00 2001 From: Grail Finder Date: Fri, 19 Dec 2025 15:39:55 +0300 Subject: Enha: agent request builder --- agent/agent.go | 10 +++ agent/request.go | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 188 insertions(+), 14 deletions(-) (limited to 'agent') diff --git a/agent/agent.go b/agent/agent.go index 5ad1ef1..8824ecb 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -33,3 +33,13 @@ func RegisterB(toolName string, a AgenterB) { func RegisterA(toolNames []string, a AgenterA) { RegistryA[a] = toolNames } + +// Get returns the agent registered for the given tool name, or nil if none. +func Get(toolName string) AgenterB { + return RegistryB[toolName] +} + +// Register is a convenience wrapper for RegisterB. +func Register(toolName string, a AgenterB) { + RegisterB(toolName, a) +} diff --git a/agent/request.go b/agent/request.go index 2d557ac..bb4a80d 100644 --- a/agent/request.go +++ b/agent/request.go @@ -3,15 +3,32 @@ package agent import ( "bytes" "encoding/json" + "fmt" "gf-lt/config" "gf-lt/models" "io" "log/slog" "net/http" + "strings" ) var httpClient = &http.Client{} +var defaultProps = map[string]float32{ + "temperature": 0.8, + "dry_multiplier": 0.0, + "min_p": 0.05, + "n_predict": -1.0, +} + +func detectAPI(api string) (isCompletion, isChat, isDeepSeek, isOpenRouter bool) { + isCompletion = strings.Contains(api, "/completion") && !strings.Contains(api, "/chat/completions") + isChat = strings.Contains(api, "/chat/completions") + isDeepSeek = strings.Contains(api, "deepseek.com") + isOpenRouter = strings.Contains(api, "openrouter.ai") + return +} + type AgentClient struct { cfg *config.Config getToken func() string @@ -31,38 +48,185 @@ func (ag *AgentClient) Log() *slog.Logger { } func (ag *AgentClient) FormMsg(sysprompt, msg string) (io.Reader, error) { - agentConvo := []models.RoleMsg{ + b, err := ag.buildRequest(sysprompt, msg) + if err != nil { + return nil, err + } + return bytes.NewReader(b), nil +} + +// buildRequest creates the appropriate LLM request based on the current API endpoint. +func (ag *AgentClient) buildRequest(sysprompt, msg string) ([]byte, error) { + api := ag.cfg.CurrentAPI + model := ag.cfg.CurrentModel + messages := []models.RoleMsg{ {Role: "system", Content: sysprompt}, {Role: "user", Content: msg}, } - agentChat := &models.ChatBody{ - Model: ag.cfg.CurrentModel, - Stream: true, - Messages: agentConvo, + + // Determine API type + isCompletion, isChat, isDeepSeek, isOpenRouter := detectAPI(api) + ag.log.Debug("agent building request", "api", api, "isCompletion", isCompletion, "isChat", isChat, "isDeepSeek", isDeepSeek, "isOpenRouter", isOpenRouter) + + // Build prompt for completion endpoints + if isCompletion { + var sb strings.Builder + for _, m := range messages { + sb.WriteString(m.ToPrompt()) + sb.WriteString("\n") + } + prompt := strings.TrimSpace(sb.String()) + + if isDeepSeek { + // DeepSeek completion + req := models.NewDSCompletionReq(prompt, model, defaultProps["temperature"], []string{}) + req.Stream = false // Agents don't need streaming + return json.Marshal(req) + } else if isOpenRouter { + // OpenRouter completion + req := models.NewOpenRouterCompletionReq(model, prompt, defaultProps, []string{}) + req.Stream = false // Agents don't need streaming + return json.Marshal(req) + } else { + // Assume llama.cpp completion + req := models.NewLCPReq(prompt, model, nil, defaultProps, []string{}) + req.Stream = false // Agents don't need streaming + return json.Marshal(req) + } } - b, err := json.Marshal(agentChat) - if err != nil { - ag.log.Error("failed to form agent msg", "error", err) - return nil, err + + // Chat completions endpoints + if isChat || !isCompletion { + chatBody := &models.ChatBody{ + Model: model, + Stream: false, // Agents don't need streaming + Messages: messages, + } + + if isDeepSeek { + // DeepSeek chat + req := models.NewDSChatReq(*chatBody) + return json.Marshal(req) + } else if isOpenRouter { + // OpenRouter chat + req := models.NewOpenRouterChatReq(*chatBody, defaultProps) + return json.Marshal(req) + } else { + // Assume llama.cpp chat (OpenAI format) + req := models.OpenAIReq{ + ChatBody: chatBody, + Tools: nil, + } + return json.Marshal(req) + } } - return bytes.NewReader(b), nil + + // Fallback (should not reach here) + ag.log.Warn("unknown API, using default chat completions format", "api", api) + chatBody := &models.ChatBody{ + Model: model, + Stream: false, // Agents don't need streaming + Messages: messages, + } + return json.Marshal(chatBody) } func (ag *AgentClient) LLMRequest(body io.Reader) ([]byte, error) { - req, err := http.NewRequest("POST", ag.cfg.CurrentAPI, body) + // Read the body for debugging (but we need to recreate it for the request) + bodyBytes, err := io.ReadAll(body) + if err != nil { + ag.log.Error("failed to read request body", "error", err) + return nil, err + } + + req, err := http.NewRequest("POST", ag.cfg.CurrentAPI, bytes.NewReader(bodyBytes)) if err != nil { - ag.log.Error("llamacpp api", "error", err) + ag.log.Error("failed to create request", "error", err) return nil, err } req.Header.Add("Accept", "application/json") req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", "Bearer "+ag.getToken()) req.Header.Set("Accept-Encoding", "gzip") + + ag.log.Debug("agent LLM request", "url", ag.cfg.CurrentAPI, "body_preview", string(bodyBytes[:min(len(bodyBytes), 500)])) + resp, err := httpClient.Do(req) if err != nil { - ag.log.Error("llamacpp api", "error", err) + ag.log.Error("llamacpp api request failed", "error", err, "url", ag.cfg.CurrentAPI) return nil, err } defer resp.Body.Close() - return io.ReadAll(resp.Body) + + responseBytes, err := io.ReadAll(resp.Body) + if err != nil { + ag.log.Error("failed to read response", "error", err) + return nil, err + } + + if resp.StatusCode >= 400 { + ag.log.Error("agent LLM request failed", "status", resp.StatusCode, "response", string(responseBytes[:min(len(responseBytes), 1000)])) + return responseBytes, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(responseBytes[:min(len(responseBytes), 200)])) + } + + // Parse response and extract text content + text, err := extractTextFromResponse(responseBytes) + if err != nil { + ag.log.Error("failed to extract text from response", "error", err, "response_preview", string(responseBytes[:min(len(responseBytes), 500)])) + // Return raw response as fallback + return responseBytes, nil + } + + return []byte(text), nil +} + +// extractTextFromResponse parses common LLM response formats and extracts the text content. +func extractTextFromResponse(data []byte) (string, error) { + // Try to parse as generic JSON first + var genericResp map[string]interface{} + if err := json.Unmarshal(data, &genericResp); err != nil { + // Not JSON, return as string + return string(data), nil + } + + // Check for OpenAI chat completion format + if choices, ok := genericResp["choices"].([]interface{}); ok && len(choices) > 0 { + if firstChoice, ok := choices[0].(map[string]interface{}); ok { + // Chat completion: choices[0].message.content + if message, ok := firstChoice["message"].(map[string]interface{}); ok { + if content, ok := message["content"].(string); ok { + return content, nil + } + } + // Completion: choices[0].text + if text, ok := firstChoice["text"].(string); ok { + return text, nil + } + // Delta format for streaming (should not happen with stream: false) + if delta, ok := firstChoice["delta"].(map[string]interface{}); ok { + if content, ok := delta["content"].(string); ok { + return content, nil + } + } + } + } + + // Check for llama.cpp completion format + if content, ok := genericResp["content"].(string); ok { + return content, nil + } + + // Unknown format, return pretty-printed JSON + prettyJSON, err := json.MarshalIndent(genericResp, "", " ") + if err != nil { + return string(data), nil + } + return string(prettyJSON), nil +} + +func min(a, b int) int { + if a < b { + return a + } + return b } -- cgit v1.2.3