Feat: add agent entity
This commit is contained in:
60
agent/agent.go
Normal file
60
agent/agent.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
120
agent/format.go
Normal file
120
agent/format.go
Normal file
@@ -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] + "..."
|
||||||
|
}
|
||||||
4
bot.go
4
bot.go
@@ -756,7 +756,7 @@ func findCall(msg, toolCall string, tv *tview.TextView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// call a func
|
// call a func
|
||||||
f, ok := fnMap[fc.Name]
|
_, ok := fnMap[fc.Name]
|
||||||
if !ok {
|
if !ok {
|
||||||
m := fc.Name + " is not implemented"
|
m := fc.Name + " is not implemented"
|
||||||
// Create tool response message with the proper tool_call_id
|
// Create tool response message with the proper tool_call_id
|
||||||
@@ -775,7 +775,7 @@ func findCall(msg, toolCall string, tv *tview.TextView) {
|
|||||||
chatRound("", cfg.AssistantRole, tv, false, false)
|
chatRound("", cfg.AssistantRole, tv, false, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp := f(fc.Args)
|
resp := callToolWithAgent(fc.Name, fc.Args)
|
||||||
toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting
|
toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting
|
||||||
logger.Info("llm used tool call", "tool_resp", toolMsg, "tool_attrs", fc)
|
logger.Info("llm used tool call", "tool_resp", toolMsg, "tool_attrs", fc)
|
||||||
fmt.Fprintf(tv, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
|
fmt.Fprintf(tv, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n",
|
||||||
|
|||||||
24
tools.go
24
tools.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gf-lt/agent"
|
||||||
"gf-lt/extra"
|
"gf-lt/extra"
|
||||||
"gf-lt/models"
|
"gf-lt/models"
|
||||||
"io"
|
"io"
|
||||||
@@ -848,6 +849,29 @@ var fnMap = map[string]fnSig{
|
|||||||
"todo_delete": todoDelete,
|
"todo_delete": todoDelete,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// callToolWithAgent calls the tool and applies any registered agent.
|
||||||
|
func callToolWithAgent(name string, args map[string]string) []byte {
|
||||||
|
f, ok := fnMap[name]
|
||||||
|
if !ok {
|
||||||
|
return []byte(fmt.Sprintf("tool %s not found", name))
|
||||||
|
}
|
||||||
|
raw := f(args)
|
||||||
|
if a := agent.Get(name); a != nil {
|
||||||
|
return a.Process(args, raw)
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerDefaultAgents registers default agents for formatting.
|
||||||
|
func registerDefaultAgents() {
|
||||||
|
agent.Register("websearch", agent.DefaultFormatter("websearch"))
|
||||||
|
agent.Register("read_url", agent.DefaultFormatter("read_url"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerDefaultAgents()
|
||||||
|
}
|
||||||
|
|
||||||
// openai style def
|
// openai style def
|
||||||
var baseTools = []models.Tool{
|
var baseTools = []models.Tool{
|
||||||
// websearch
|
// websearch
|
||||||
|
|||||||
Reference in New Issue
Block a user