Changeset 0.7.2 (#40)

This commit is contained in:
2026-02-21 19:03:19 +00:00
parent 494b1aa981
commit 416e5439ea
28 changed files with 3813 additions and 138 deletions

372
server/tools/notes.go Normal file
View 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
View 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
View 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
View 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
View 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"},
}
}