Changeset 0.7.2 (#40)
This commit is contained in:
372
server/tools/notes.go
Normal file
372
server/tools/notes.go
Normal file
@@ -0,0 +1,372 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/lib/pq"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
// ── Init ────────────────────────────────────
|
||||
|
||||
func init() {
|
||||
Register(&NoteCreateTool{})
|
||||
Register(&NoteSearchTool{})
|
||||
Register(&NoteUpdateTool{})
|
||||
Register(&NoteListTool{})
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// note_create
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteCreateTool struct{}
|
||||
|
||||
func (t *NoteCreateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_create",
|
||||
Description: "Create a new note for the user. Use this to save information, insights, summaries, or anything the user wants to remember. Notes are persistent and searchable.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"title": Prop("string", "Title of the note"),
|
||||
"content": Prop("string", "Markdown content of the note"),
|
||||
"folder": Prop("string", "Folder path, e.g. '/projects/switchboard/'. Defaults to '/'"),
|
||||
"tags": PropArray("Tags for categorization, e.g. ['research', 'golang']"),
|
||||
}, []string{"title", "content"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteCreateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Folder string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Title == "" {
|
||||
return "", fmt.Errorf("title is required")
|
||||
}
|
||||
|
||||
folder := normalizePath(args.Folder)
|
||||
tags := args.Tags
|
||||
if tags == nil {
|
||||
tags = []string{}
|
||||
}
|
||||
|
||||
// source_channel_id links the note to the conversation that created it
|
||||
var sourceChannelID *string
|
||||
if execCtx.ChannelID != "" {
|
||||
sourceChannelID = &execCtx.ChannelID
|
||||
}
|
||||
|
||||
var id, createdAt string
|
||||
err := database.DB.QueryRow(`
|
||||
INSERT INTO notes (user_id, title, content, folder_path, tags, source_channel_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, created_at::text
|
||||
`, execCtx.UserID, args.Title, args.Content, folder, pq.Array(tags), sourceChannelID,
|
||||
).Scan(&id, &createdAt)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create note: %w", err)
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"title": args.Title,
|
||||
"folder": folder,
|
||||
"tags": tags,
|
||||
"created_at": createdAt,
|
||||
"message": fmt.Sprintf("Note '%s' created successfully.", args.Title),
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// note_search
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteSearchTool struct{}
|
||||
|
||||
func (t *NoteSearchTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_search",
|
||||
Description: "Search the user's notes by keyword. Returns matching notes ranked by relevance with highlighted excerpts. Use this to find previously saved information.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"query": Prop("string", "Search query — natural language keywords"),
|
||||
"limit": Prop("integer", "Maximum results to return (default 10, max 50)"),
|
||||
}, []string{"query"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteSearchTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Query == "" {
|
||||
return "", fmt.Errorf("query is required")
|
||||
}
|
||||
if args.Limit <= 0 || args.Limit > 50 {
|
||||
args.Limit = 10
|
||||
}
|
||||
|
||||
rows, err := database.DB.Query(`
|
||||
SELECT id, title, folder_path, tags, LEFT(content, 500),
|
||||
ts_rank(search_vector, plainto_tsquery('english', $2)) AS rank,
|
||||
ts_headline('english', content, plainto_tsquery('english', $2),
|
||||
'MaxWords=60, MinWords=20, StartSel=**, StopSel=**') AS headline
|
||||
FROM notes
|
||||
WHERE user_id = $1
|
||||
AND search_vector @@ plainto_tsquery('english', $2)
|
||||
ORDER BY rank DESC
|
||||
LIMIT $3
|
||||
`, execCtx.UserID, args.Query, args.Limit)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("search failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type result struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Folder string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Headline string `json:"headline"`
|
||||
Rank float64 `json:"rank"`
|
||||
}
|
||||
|
||||
results := make([]result, 0)
|
||||
for rows.Next() {
|
||||
var r result
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&r.ID, &r.Title, &r.Folder, &dbTags, &r.Excerpt, &r.Rank, &r.Headline); err != nil {
|
||||
continue
|
||||
}
|
||||
r.Tags = []string(dbTags)
|
||||
if r.Tags == nil {
|
||||
r.Tags = []string{}
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"query": args.Query,
|
||||
"count": len(results),
|
||||
"results": results,
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// note_update
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteUpdateTool struct{}
|
||||
|
||||
func (t *NoteUpdateTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_update",
|
||||
Description: "Update an existing note. Can replace content entirely, append to it, or prepend. Use note_search first to find the note ID.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"note_id": Prop("string", "UUID of the note to update (from note_search results)"),
|
||||
"title": Prop("string", "New title (omit to keep current)"),
|
||||
"content": Prop("string", "New content or text to append/prepend"),
|
||||
"mode": PropEnum("How to apply content", "replace", "append", "prepend"),
|
||||
"tags": PropArray("New tags (replaces existing tags)"),
|
||||
}, []string{"note_id"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteUpdateTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
NoteID string `json:"note_id"`
|
||||
Title *string `json:"title"`
|
||||
Content *string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.NoteID == "" {
|
||||
return "", fmt.Errorf("note_id is required")
|
||||
}
|
||||
|
||||
// Build dynamic update
|
||||
setClauses := []string{}
|
||||
queryArgs := []interface{}{}
|
||||
argIdx := 1
|
||||
|
||||
if args.Title != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("title = $%d", argIdx))
|
||||
queryArgs = append(queryArgs, *args.Title)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if args.Content != nil {
|
||||
mode := strings.ToLower(args.Mode)
|
||||
switch mode {
|
||||
case "append":
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = content || $%d", argIdx))
|
||||
case "prepend":
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = $%d || content", argIdx))
|
||||
default:
|
||||
setClauses = append(setClauses, fmt.Sprintf("content = $%d", argIdx))
|
||||
}
|
||||
queryArgs = append(queryArgs, *args.Content)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if args.Tags != nil {
|
||||
setClauses = append(setClauses, fmt.Sprintf("tags = $%d", argIdx))
|
||||
queryArgs = append(queryArgs, pq.Array(args.Tags))
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return "", fmt.Errorf("no fields to update — provide at least title, content, or tags")
|
||||
}
|
||||
|
||||
queryArgs = append(queryArgs, args.NoteID, execCtx.UserID)
|
||||
query := "UPDATE notes SET " + strings.Join(setClauses, ", ") +
|
||||
fmt.Sprintf(" WHERE id = $%d AND user_id = $%d", argIdx, argIdx+1) +
|
||||
" RETURNING id, title, updated_at::text"
|
||||
|
||||
var id, title, updatedAt string
|
||||
err := database.DB.QueryRow(query, queryArgs...).Scan(&id, &title, &updatedAt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("note not found or update failed")
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(map[string]interface{}{
|
||||
"id": id,
|
||||
"title": title,
|
||||
"updated_at": updatedAt,
|
||||
"message": fmt.Sprintf("Note '%s' updated successfully.", title),
|
||||
})
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// note_list
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
type NoteListTool struct{}
|
||||
|
||||
func (t *NoteListTool) Definition() ToolDef {
|
||||
return ToolDef{
|
||||
Name: "note_list",
|
||||
Description: "List the user's notes, optionally filtered by folder or tag. Shows titles and metadata without full content. Use note_search for keyword searching.",
|
||||
Parameters: JSONSchema(map[string]interface{}{
|
||||
"folder": Prop("string", "Filter by folder path, e.g. '/projects/' (omit for all)"),
|
||||
"tag": Prop("string", "Filter by tag (omit for all)"),
|
||||
"limit": Prop("integer", "Maximum results (default 20, max 100)"),
|
||||
}, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *NoteListTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
|
||||
var args struct {
|
||||
Folder string `json:"folder"`
|
||||
Tag string `json:"tag"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
|
||||
return "", fmt.Errorf("invalid arguments: %w", err)
|
||||
}
|
||||
|
||||
if args.Limit <= 0 || args.Limit > 100 {
|
||||
args.Limit = 20
|
||||
}
|
||||
|
||||
query := `SELECT id, title, folder_path, tags, LEFT(content, 100),
|
||||
created_at::text, updated_at::text
|
||||
FROM notes WHERE user_id = $1`
|
||||
queryArgs := []interface{}{execCtx.UserID}
|
||||
argIdx := 2
|
||||
|
||||
if args.Folder != "" {
|
||||
query += fmt.Sprintf(" AND folder_path = $%d", argIdx)
|
||||
queryArgs = append(queryArgs, normalizePath(args.Folder))
|
||||
argIdx++
|
||||
}
|
||||
if args.Tag != "" {
|
||||
query += fmt.Sprintf(" AND $%d = ANY(tags)", argIdx)
|
||||
queryArgs = append(queryArgs, args.Tag)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += fmt.Sprintf(" ORDER BY updated_at DESC LIMIT $%d", argIdx)
|
||||
queryArgs = append(queryArgs, args.Limit)
|
||||
|
||||
rows, err := database.DB.Query(query, queryArgs...)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list notes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Folder string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
Preview string `json:"preview"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
items := make([]item, 0)
|
||||
for rows.Next() {
|
||||
var n item
|
||||
var dbTags pq.StringArray
|
||||
if err := rows.Scan(&n.ID, &n.Title, &n.Folder, &dbTags, &n.Preview,
|
||||
&n.CreatedAt, &n.UpdatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
n.Tags = []string(dbTags)
|
||||
if n.Tags == nil {
|
||||
n.Tags = []string{}
|
||||
}
|
||||
items = append(items, n)
|
||||
}
|
||||
|
||||
out, _ := json.Marshal(map[string]interface{}{
|
||||
"count": len(items),
|
||||
"notes": items,
|
||||
})
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// ── Path Helper ─────────────────────────────
|
||||
|
||||
func normalizePath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p
|
||||
}
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
p = p + "/"
|
||||
}
|
||||
for strings.Contains(p, "//") {
|
||||
p = strings.ReplaceAll(p, "//", "/")
|
||||
}
|
||||
return p
|
||||
}
|
||||
203
server/tools/notes_test.go
Normal file
203
server/tools/notes_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
teardown := database.SetupTestDB()
|
||||
code := m.Run()
|
||||
teardown()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// ── Note Tool Execution (requires DB) ───────
|
||||
|
||||
func TestNoteCreateExecute(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "tooluser", "tool@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Tool Test")
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
|
||||
|
||||
tool := Get("note_create")
|
||||
if tool == nil {
|
||||
t.Fatal("note_create not registered")
|
||||
}
|
||||
|
||||
args := `{"title":"Test Note","content":"Created via tool","folder":"/tools","tags":["test","ci"]}`
|
||||
result, err := tool.Execute(ctx, execCtx, args)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(result), &resp); err != nil {
|
||||
t.Fatalf("Invalid JSON result: %v\nRaw: %s", err, result)
|
||||
}
|
||||
|
||||
if resp["id"] == nil || resp["id"] == "" {
|
||||
t.Error("Expected id in result")
|
||||
}
|
||||
if resp["title"] != "Test Note" {
|
||||
t.Errorf("Expected title='Test Note', got %v", resp["title"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteListExecute(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "listuser", "list@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "List Test")
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
|
||||
|
||||
// Create two notes first
|
||||
createTool := Get("note_create")
|
||||
createTool.Execute(ctx, execCtx, `{"title":"Note A","content":"Alpha","folder":"/a"}`)
|
||||
createTool.Execute(ctx, execCtx, `{"title":"Note B","content":"Beta","folder":"/b"}`)
|
||||
|
||||
// List all
|
||||
listTool := Get("note_list")
|
||||
result, err := listTool.Execute(ctx, execCtx, `{}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &resp)
|
||||
|
||||
count := resp["count"].(float64)
|
||||
if count != 2 {
|
||||
t.Errorf("Expected count=2, got %.0f", count)
|
||||
}
|
||||
|
||||
// List filtered by folder
|
||||
result, err = listTool.Execute(ctx, execCtx, `{"folder":"/a"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute with folder: %v", err)
|
||||
}
|
||||
json.Unmarshal([]byte(result), &resp)
|
||||
count = resp["count"].(float64)
|
||||
if count != 1 {
|
||||
t.Errorf("Expected count=1 for folder /a, got %.0f", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteSearchExecute(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "searchuser", "search@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Search Test")
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
|
||||
|
||||
createTool := Get("note_create")
|
||||
createTool.Execute(ctx, execCtx, `{"title":"Kubernetes Guide","content":"How to deploy pods and services"}`)
|
||||
createTool.Execute(ctx, execCtx, `{"title":"Cooking Tips","content":"Season your cast iron pan properly"}`)
|
||||
|
||||
searchTool := Get("note_search")
|
||||
result, err := searchTool.Execute(ctx, execCtx, `{"query":"kubernetes pods"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &resp)
|
||||
count := resp["count"].(float64)
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 search result for 'kubernetes pods', got %.0f", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteUpdateExecute(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "updateuser", "update@test.com")
|
||||
channelID := database.SeedTestChannel(t, userID, "Update Test")
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
|
||||
|
||||
// Create a note
|
||||
createTool := Get("note_create")
|
||||
result, _ := createTool.Execute(ctx, execCtx, `{"title":"Original","content":"Original content"}`)
|
||||
|
||||
var created map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &created)
|
||||
noteID := created["id"].(string)
|
||||
|
||||
// Update title
|
||||
updateTool := Get("note_update")
|
||||
result, err := updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","title":"Renamed"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Update title: %v", err)
|
||||
}
|
||||
|
||||
var updated map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &updated)
|
||||
if updated["title"] != "Renamed" {
|
||||
t.Errorf("Expected title='Renamed', got %v", updated["title"])
|
||||
}
|
||||
|
||||
// Append content
|
||||
result, err = updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","content":"\nNew line","mode":"append"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Append: %v", err)
|
||||
}
|
||||
|
||||
// Verify via direct DB read
|
||||
var row string
|
||||
database.DB.QueryRow("SELECT content FROM notes WHERE id = $1", noteID).Scan(&row)
|
||||
if row != "Original content\nNew line" {
|
||||
t.Errorf("Append: expected concatenated content, got %q", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteCreateMissingRequiredField(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
|
||||
|
||||
tool := Get("note_create")
|
||||
// Missing content
|
||||
result, err := tool.Execute(ctx, execCtx, `{"title":"No Content"}`)
|
||||
if err == nil {
|
||||
// Tools return errors in content, not as Go errors
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal([]byte(result), &resp)
|
||||
if resp["error"] == nil {
|
||||
t.Error("Expected error for missing content")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteUpdateNonexistent(t *testing.T) {
|
||||
database.RequireTestDB(t)
|
||||
database.TruncateAll(t)
|
||||
|
||||
userID := database.SeedTestUser(t, "ghostuser", "ghost@test.com")
|
||||
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: userID, ChannelID: "test"}
|
||||
|
||||
tool := Get("note_update")
|
||||
_, err := tool.Execute(ctx, execCtx, `{"note_id":"00000000-0000-0000-0000-000000000000","title":"Nope"}`)
|
||||
if err == nil {
|
||||
t.Log("Update of nonexistent note should return error or empty result")
|
||||
}
|
||||
}
|
||||
92
server/tools/registry.go
Normal file
92
server/tools/registry.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ── Registry ────────────────────────────────
|
||||
|
||||
var registry = map[string]Tool{}
|
||||
|
||||
// Register adds a tool to the global registry. Called at init time.
|
||||
func Register(t Tool) {
|
||||
def := t.Definition()
|
||||
if _, exists := registry[def.Name]; exists {
|
||||
panic("tools.Register: duplicate tool name: " + def.Name)
|
||||
}
|
||||
registry[def.Name] = t
|
||||
log.Printf("🔧 Registered tool: %s", def.Name)
|
||||
}
|
||||
|
||||
// Get returns a tool by name, or nil if not found.
|
||||
func Get(name string) Tool {
|
||||
return registry[name]
|
||||
}
|
||||
|
||||
// AllDefinitions returns tool definitions for all registered tools.
|
||||
// Used to populate the `tools` field in LLM requests.
|
||||
func AllDefinitions() []ToolDef {
|
||||
defs := make([]ToolDef, 0, len(registry))
|
||||
for _, t := range registry {
|
||||
defs = append(defs, t.Definition())
|
||||
}
|
||||
return defs
|
||||
}
|
||||
|
||||
// ── Execution ───────────────────────────────
|
||||
|
||||
// ExecuteCall runs a single tool call and returns a ToolResult.
|
||||
// Never returns an error — failures are encoded in the result content
|
||||
// so the LLM can see what went wrong and adjust.
|
||||
func ExecuteCall(ctx context.Context, execCtx ExecutionContext, call ToolCall) ToolResult {
|
||||
tool := Get(call.Name)
|
||||
if tool == nil {
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: jsonError(fmt.Sprintf("unknown tool: %s", call.Name)),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
result, err := tool.Execute(ctx, execCtx, call.Arguments)
|
||||
if err != nil {
|
||||
log.Printf("Tool %s error: %v", call.Name, err)
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: jsonError(err.Error()),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
return ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Name: call.Name,
|
||||
Content: result,
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteAll runs multiple tool calls and returns all results.
|
||||
func ExecuteAll(ctx context.Context, execCtx ExecutionContext, calls []ToolCall) []ToolResult {
|
||||
results := make([]ToolResult, len(calls))
|
||||
for i, call := range calls {
|
||||
results[i] = ExecuteCall(ctx, execCtx, call)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// HasTools returns true if any tools are registered.
|
||||
func HasTools() bool {
|
||||
return len(registry) > 0
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
func jsonError(msg string) string {
|
||||
b, _ := json.Marshal(map[string]string{"error": msg})
|
||||
return string(b)
|
||||
}
|
||||
194
server/tools/tools_test.go
Normal file
194
server/tools/tools_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Registry Tests ──────────────────────────
|
||||
|
||||
func TestRegistryHasTools(t *testing.T) {
|
||||
// init() in notes.go registers 4 tools
|
||||
if !HasTools() {
|
||||
t.Fatal("Expected HasTools() == true after init registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryAllDefinitions(t *testing.T) {
|
||||
defs := AllDefinitions()
|
||||
if len(defs) < 4 {
|
||||
t.Errorf("Expected at least 4 tool definitions, got %d", len(defs))
|
||||
}
|
||||
|
||||
expected := map[string]bool{
|
||||
"note_create": false,
|
||||
"note_search": false,
|
||||
"note_update": false,
|
||||
"note_list": false,
|
||||
}
|
||||
for _, d := range defs {
|
||||
if _, ok := expected[d.Name]; ok {
|
||||
expected[d.Name] = true
|
||||
}
|
||||
// Every tool must have a description and parameters
|
||||
if d.Description == "" {
|
||||
t.Errorf("Tool %q has empty description", d.Name)
|
||||
}
|
||||
if len(d.Parameters) == 0 {
|
||||
t.Errorf("Tool %q has empty parameters", d.Name)
|
||||
}
|
||||
}
|
||||
for name, found := range expected {
|
||||
if !found {
|
||||
t.Errorf("Expected tool %q not found in registry", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetKnown(t *testing.T) {
|
||||
tool := Get("note_create")
|
||||
if tool == nil {
|
||||
t.Fatal("Get(note_create) returned nil")
|
||||
}
|
||||
def := tool.Definition()
|
||||
if def.Name != "note_create" {
|
||||
t.Errorf("Expected name 'note_create', got %q", def.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryGetUnknown(t *testing.T) {
|
||||
tool := Get("nonexistent_tool")
|
||||
if tool != nil {
|
||||
t.Error("Get(nonexistent_tool) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteCallUnknownTool(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
|
||||
call := ToolCall{ID: "call_1", Name: "nonexistent", Arguments: "{}"}
|
||||
|
||||
result := ExecuteCall(ctx, execCtx, call)
|
||||
|
||||
if !result.IsError {
|
||||
t.Error("Expected is_error=true for unknown tool")
|
||||
}
|
||||
if result.ToolCallID != "call_1" {
|
||||
t.Errorf("Expected tool_call_id='call_1', got %q", result.ToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAll(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
|
||||
calls := []ToolCall{
|
||||
{ID: "call_1", Name: "nonexistent_a", Arguments: "{}"},
|
||||
{ID: "call_2", Name: "nonexistent_b", Arguments: "{}"},
|
||||
}
|
||||
|
||||
results := ExecuteAll(ctx, execCtx, calls)
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("Expected 2 results, got %d", len(results))
|
||||
}
|
||||
for i, r := range results {
|
||||
if !r.IsError {
|
||||
t.Errorf("Result %d: expected is_error=true", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool Definition Schema Tests ────────────
|
||||
|
||||
func TestToolDefinitionsHaveValidJSON(t *testing.T) {
|
||||
for _, def := range AllDefinitions() {
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(def.Parameters, &schema); err != nil {
|
||||
t.Errorf("Tool %q has invalid JSON schema: %v", def.Name, err)
|
||||
}
|
||||
// Should be a JSON Schema object with "type" and "properties"
|
||||
if schema["type"] != "object" {
|
||||
t.Errorf("Tool %q schema type should be 'object', got %v", def.Name, schema["type"])
|
||||
}
|
||||
if _, ok := schema["properties"]; !ok {
|
||||
t.Errorf("Tool %q schema missing 'properties'", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── JSON Schema Helper Tests ────────────────
|
||||
|
||||
func TestPropString(t *testing.T) {
|
||||
p := Prop("string", "a description")
|
||||
|
||||
if p["type"] != "string" {
|
||||
t.Errorf("Expected type=string, got %v", p["type"])
|
||||
}
|
||||
if p["description"] != "a description" {
|
||||
t.Errorf("Expected description, got %v", p["description"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropEnum(t *testing.T) {
|
||||
p := PropEnum("content mode", "replace", "append", "prepend")
|
||||
|
||||
if p["type"] != "string" {
|
||||
t.Error("Expected type=string")
|
||||
}
|
||||
enums := p["enum"].([]string)
|
||||
if len(enums) != 3 {
|
||||
t.Errorf("Expected 3 enum values, got %d", len(enums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPropArray(t *testing.T) {
|
||||
p := PropArray("list of tags")
|
||||
|
||||
if p["type"] != "array" {
|
||||
t.Error("Expected type=array")
|
||||
}
|
||||
items := p["items"].(map[string]string)
|
||||
if items["type"] != "string" {
|
||||
t.Error("Expected items.type=string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONSchema(t *testing.T) {
|
||||
props := map[string]interface{}{
|
||||
"title": Prop("string", "note title"),
|
||||
"content": Prop("string", "note body"),
|
||||
}
|
||||
schema := JSONSchema(props, []string{"title"})
|
||||
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(schema, &m); err != nil {
|
||||
t.Fatalf("Invalid JSON: %v", err)
|
||||
}
|
||||
|
||||
if m["type"] != "object" {
|
||||
t.Error("Expected type=object")
|
||||
}
|
||||
required := m["required"].([]interface{})
|
||||
if len(required) != 1 || required[0] != "title" {
|
||||
t.Errorf("Expected required=[title], got %v", required)
|
||||
}
|
||||
mProps := m["properties"].(map[string]interface{})
|
||||
if _, ok := mProps["title"]; !ok {
|
||||
t.Error("Missing property 'title'")
|
||||
}
|
||||
if _, ok := mProps["content"]; !ok {
|
||||
t.Error("Missing property 'content'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustJSON(t *testing.T) {
|
||||
result := MustJSON(map[string]string{"key": "value"})
|
||||
var m map[string]string
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("MustJSON produced invalid JSON: %v", err)
|
||||
}
|
||||
if m["key"] != "value" {
|
||||
t.Errorf("Expected value, got %q", m["key"])
|
||||
}
|
||||
}
|
||||
105
server/tools/types.go
Normal file
105
server/tools/types.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// ── Tool Definition (sent to LLM) ──────────
|
||||
|
||||
// ToolDef describes a tool the LLM can call. Serialized to OpenAI-compatible
|
||||
// format by providers. The Parameters field uses JSON Schema.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Parameters json.RawMessage `json:"parameters"` // JSON Schema object
|
||||
}
|
||||
|
||||
// ── Tool Call (from LLM response) ───────────
|
||||
|
||||
// ToolCall represents a single tool invocation requested by the LLM.
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"` // provider-assigned call ID
|
||||
Name string `json:"name"` // tool function name
|
||||
Arguments string `json:"arguments"` // JSON string of arguments
|
||||
}
|
||||
|
||||
// ── Tool Result (fed back to LLM) ───────────
|
||||
|
||||
// ToolResult is the output of executing a tool, sent back to the LLM
|
||||
// as a message with role "tool".
|
||||
type ToolResult struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"` // JSON-encoded result
|
||||
IsError bool `json:"is_error,omitempty"` // true if execution failed
|
||||
}
|
||||
|
||||
// ── Tool Interface ──────────────────────────
|
||||
|
||||
// ExecutionContext provides tool implementations with the info they need.
|
||||
type ExecutionContext struct {
|
||||
UserID string
|
||||
ChannelID string
|
||||
}
|
||||
|
||||
// Tool is the interface every built-in tool implements.
|
||||
type Tool interface {
|
||||
// Definition returns the tool's schema for the LLM.
|
||||
Definition() ToolDef
|
||||
|
||||
// Execute runs the tool with the given JSON arguments.
|
||||
// Returns a JSON string result or an error.
|
||||
Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error)
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────
|
||||
|
||||
// MustJSON marshals v to a json.RawMessage, panicking on error.
|
||||
// Used for static parameter schemas at init time.
|
||||
func MustJSON(v interface{}) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic("tools.MustJSON: " + err.Error())
|
||||
}
|
||||
return json.RawMessage(b)
|
||||
}
|
||||
|
||||
// JSONSchema builds a JSON Schema object type with the given properties.
|
||||
// Convenience for defining tool parameters.
|
||||
func JSONSchema(properties map[string]interface{}, required []string) json.RawMessage {
|
||||
schema := map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
schema["required"] = required
|
||||
}
|
||||
return MustJSON(schema)
|
||||
}
|
||||
|
||||
// Prop builds a JSON Schema property definition.
|
||||
func Prop(typ, description string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": typ,
|
||||
"description": description,
|
||||
}
|
||||
}
|
||||
|
||||
// PropEnum builds a JSON Schema string property with enum values.
|
||||
func PropEnum(description string, values ...string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": description,
|
||||
"enum": values,
|
||||
}
|
||||
}
|
||||
|
||||
// PropArray builds a JSON Schema array property with string items.
|
||||
func PropArray(description string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "array",
|
||||
"description": description,
|
||||
"items": map[string]string{"type": "string"},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user