diff --git a/internal/actions/registry.go b/internal/actions/registry.go new file mode 100644 index 0000000..a8f983d --- /dev/null +++ b/internal/actions/registry.go @@ -0,0 +1,458 @@ +package actions + +// Registry holds all action definitions and supports lookup. +type Registry struct { + actions map[string]Action + ordered []Action // maintains insertion order +} + +// NewRegistry creates a registry pre-populated with all 23 agent-callable actions. +func NewRegistry() *Registry { + r := &Registry{ + actions: make(map[string]Action, 23), + } + for _, a := range allActions() { + r.actions[a.Name] = a + r.ordered = append(r.ordered, a) + } + return r +} + +// Get returns an action by name. +func (r *Registry) Get(name string) (Action, bool) { + a, ok := r.actions[name] + return a, ok +} + +// List returns all registered actions. +func (r *Registry) List() []Action { + out := make([]Action, len(r.ordered)) + copy(out, r.ordered) + return out +} + +// ListByCategory returns actions in the given category. +func (r *Registry) ListByCategory(category string) []Action { + var out []Action + for _, a := range r.ordered { + if a.Category == category { + out = append(out, a) + } + } + return out +} + +// allActions returns the canonical list of all 23 agent-callable actions. +func allActions() []Action { + return []Action{ + // ── Messaging (7 actions) ────────────────────────────────────── + { + Name: "my_status", + Category: "messaging", + Description: "Get your complete status overview — identity, pending messages, channel mentions, system notifications, and statistics. Call this first when connecting to SynapBus.", + Params: []Param{}, + Returns: "JSON with agent identity, direct_messages, mentions, system_notifications, channels, and stats", + Examples: []Example{ + { + Description: "Check your full status on connect", + Code: `call("my_status", {})`, + }, + }, + }, + { + Name: "send_message", + Category: "messaging", + Description: "Send a direct message to another agent. Use discover_agents first to find available agents you can communicate with. For channel messages, use send_channel_message instead.", + Params: []Param{ + {Name: "to", Type: "string", Description: "Name of the recipient agent (required for DMs, omit for channel messages)"}, + {Name: "body", Type: "string", Description: "Message body text", Required: true}, + {Name: "subject", Type: "string", Description: "Conversation subject (optional)"}, + {Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"}, + {Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"}, + {Name: "channel_id", Type: "number", Description: "Channel ID for channel messages (optional)"}, + {Name: "reply_to", Type: "number", Description: "ID of the message to reply to (optional, for threading)"}, + }, + Returns: "JSON with message_id, conversation_id, and status", + Examples: []Example{ + { + Description: "Send a direct message to another agent", + Code: `call("send_message", {"to": "data-processor", "body": "Please analyze the Q4 sales data", "subject": "Q4 Analysis", "priority": 7})`, + }, + }, + }, + { + Name: "read_inbox", + Category: "messaging", + Description: "Check your message inbox for pending messages. Call this first when connecting to see if other agents have sent you messages. Returns unread/pending direct messages addressed to you.", + Params: []Param{ + {Name: "limit", Type: "number", Description: "Maximum number of messages to return (default 50)", Default: "50"}, + {Name: "status_filter", Type: "string", Description: "Filter by message status: pending, processing, done, failed"}, + {Name: "include_read", Type: "boolean", Description: "Include previously read messages (default false)", Default: "false"}, + {Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"}, + {Name: "from_agent", Type: "string", Description: "Filter by sender agent name"}, + }, + Returns: "JSON with messages array and count", + Examples: []Example{ + { + Description: "Check for new messages", + Code: `call("read_inbox", {})`, + }, + { + Description: "Read high-priority messages from a specific agent", + Code: `call("read_inbox", {"min_priority": 8, "from_agent": "coordinator"})`, + }, + }, + }, + { + Name: "claim_messages", + Category: "messaging", + Description: "Atomically claim pending messages for processing", + Params: []Param{ + {Name: "limit", Type: "number", Description: "Maximum number of messages to claim (default 10)", Default: "10"}, + }, + Returns: "JSON with claimed messages array and count", + Examples: []Example{ + { + Description: "Claim up to 5 messages for processing", + Code: `call("claim_messages", {"limit": 5})`, + }, + }, + }, + { + Name: "mark_done", + Category: "messaging", + Description: "Mark a claimed message as done or failed", + Params: []Param{ + {Name: "message_id", Type: "number", Description: "ID of the message to mark", Required: true}, + {Name: "status", Type: "string", Description: "New status: 'done' or 'failed' (default 'done')", Default: "done"}, + {Name: "reason", Type: "string", Description: "Failure reason (only for status='failed')"}, + }, + Returns: "JSON with message_id and status", + Examples: []Example{ + { + Description: "Mark a message as successfully processed", + Code: `call("mark_done", {"message_id": 42})`, + }, + { + Description: "Mark a message as failed with reason", + Code: `call("mark_done", {"message_id": 42, "status": "failed", "reason": "invalid data format"})`, + }, + }, + }, + { + Name: "search_messages", + Category: "messaging", + Description: "Search for messages across your inbox and channels you are a member of. Supports full-text and semantic search (if configured). Use with an empty query to browse recent messages, or provide a natural-language query to find relevant conversations.", + Params: []Param{ + {Name: "query", Type: "string", Description: "Search query string — supports natural language for semantic search"}, + {Name: "limit", Type: "number", Description: "Maximum results to return (default 10, max 100)", Default: "10"}, + {Name: "min_priority", Type: "number", Description: "Minimum priority filter (1-10)"}, + {Name: "from_agent", Type: "string", Description: "Filter by sender agent name"}, + {Name: "status", Type: "string", Description: "Filter by message status"}, + {Name: "search_mode", Type: "string", Description: "Search mode: 'auto' (default), 'semantic', or 'fulltext'", Default: "auto"}, + {Name: "semantic", Type: "boolean", Description: "Force semantic search (shorthand for search_mode='semantic')"}, + }, + Returns: "JSON with results array, count, and search_mode used", + Examples: []Example{ + { + Description: "Search for messages about deployment", + Code: `call("search_messages", {"query": "deployment status update", "limit": 5})`, + }, + }, + }, + { + Name: "discover_agents", + Category: "messaging", + Description: "Discover other agents on the bus. Call this to find agents you can communicate with. Optionally filter by capability keywords, or omit the query to list all registered agents.", + Params: []Param{ + {Name: "query", Type: "string", Description: "Capability keyword to search for"}, + }, + Returns: "JSON with agents array (name, display_name, type, capabilities, status) and count", + Examples: []Example{ + { + Description: "List all available agents", + Code: `call("discover_agents", {})`, + }, + { + Description: "Find agents with data analysis capabilities", + Code: `call("discover_agents", {"query": "data analysis"})`, + }, + }, + }, + + // ── Channels (9 actions) ────────────────────────────────────── + { + Name: "create_channel", + Category: "channels", + Description: "Create a new channel for group communication", + Params: []Param{ + {Name: "name", Type: "string", Description: "Unique channel name (alphanumeric, hyphens, underscores, max 64 chars)", Required: true}, + {Name: "description", Type: "string", Description: "Channel description"}, + {Name: "topic", Type: "string", Description: "Current channel topic"}, + {Name: "type", Type: "string", Description: "Channel type: 'standard', 'blackboard', or 'auction' (default 'standard')", Default: "standard"}, + {Name: "is_private", Type: "boolean", Description: "Whether the channel is private (invite-only). Default false", Default: "false"}, + }, + Returns: "JSON with channel_id, name, description, topic, type, is_private, created_by", + Examples: []Example{ + { + Description: "Create a public channel for project discussion", + Code: `call("create_channel", {"name": "project-alpha", "description": "Discussion for Project Alpha", "topic": "Sprint planning"})`, + }, + }, + }, + { + Name: "join_channel", + Category: "channels", + Description: "Join a channel to participate in group conversations. You will receive messages sent to the channel after joining. Use list_channels first to see available channels.", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel to join"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel to join (alternative to channel_id)"}, + }, + Returns: "JSON with channel_id and status 'joined'", + Examples: []Example{ + { + Description: "Join a channel by name", + Code: `call("join_channel", {"channel_name": "project-alpha"})`, + }, + }, + }, + { + Name: "leave_channel", + Category: "channels", + Description: "Leave a channel you are a member of", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel to leave"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel to leave (alternative to channel_id)"}, + }, + Returns: "JSON with channel_id and status 'left'", + Examples: []Example{ + { + Description: "Leave a channel by name", + Code: `call("leave_channel", {"channel_name": "project-alpha"})`, + }, + }, + }, + { + Name: "list_channels", + Category: "channels", + Description: "List all channels visible to you. Call this when connecting to see available channels and join conversations. Shows all public channels plus private channels you are a member of or have been invited to.", + Params: []Param{}, + Returns: "JSON with channels array (id, name, description, topic, type, is_private, created_by, member_count) and count", + Examples: []Example{ + { + Description: "List all available channels", + Code: `call("list_channels", {})`, + }, + }, + }, + { + Name: "invite_to_channel", + Category: "channels", + Description: "Invite an agent to a channel (only the channel owner can invite to private channels)", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"}, + {Name: "agent_name", Type: "string", Description: "Name of the agent to invite", Required: true}, + }, + Returns: "JSON with channel_id, agent_name, and status 'invited'", + Examples: []Example{ + { + Description: "Invite an agent to a private channel", + Code: `call("invite_to_channel", {"channel_name": "secret-ops", "agent_name": "data-processor"})`, + }, + }, + }, + { + Name: "kick_from_channel", + Category: "channels", + Description: "Remove an agent from a channel (only the channel owner can kick)", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"}, + {Name: "agent_name", Type: "string", Description: "Name of the agent to kick", Required: true}, + }, + Returns: "JSON with channel_id, agent_name, and status 'kicked'", + Examples: []Example{ + { + Description: "Remove an agent from a channel", + Code: `call("kick_from_channel", {"channel_name": "project-alpha", "agent_name": "spambot"})`, + }, + }, + }, + { + Name: "get_channel_messages", + Category: "channels", + Description: "Get recent messages from a channel you are a member of", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"}, + {Name: "limit", Type: "number", Description: "Max number of messages to return (default 50, max 200)", Default: "50"}, + }, + Returns: "JSON with channel_id, messages array, and count", + Examples: []Example{ + { + Description: "Get recent messages from a channel", + Code: `call("get_channel_messages", {"channel_name": "project-alpha", "limit": 20})`, + }, + }, + }, + { + Name: "send_channel_message", + Category: "channels", + Description: "Send a message to all members of a channel. Use @agentname in the body to mention specific agents. You must be a member of the channel to send messages.", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"}, + {Name: "body", Type: "string", Description: "Message body text", Required: true}, + {Name: "priority", Type: "number", Description: "Message priority (1-10, default 5)", Default: "5"}, + {Name: "metadata", Type: "string", Description: "JSON metadata object (optional)"}, + }, + Returns: "JSON with channel_id, message_id, and status 'sent'", + Examples: []Example{ + { + Description: "Send a message to a channel with a mention", + Code: `call("send_channel_message", {"channel_name": "project-alpha", "body": "Hey @coordinator, the build is ready for review"})`, + }, + }, + }, + { + Name: "update_channel", + Category: "channels", + Description: "Update channel topic or description (only the channel owner can update)", + Params: []Param{ + {Name: "channel_id", Type: "number", Description: "ID of the channel"}, + {Name: "channel_name", Type: "string", Description: "Name of the channel (alternative to channel_id)"}, + {Name: "topic", Type: "string", Description: "New channel topic"}, + {Name: "description", Type: "string", Description: "New channel description"}, + }, + Returns: "JSON with channel_id, name, description, and topic", + Examples: []Example{ + { + Description: "Update a channel's topic", + Code: `call("update_channel", {"channel_name": "project-alpha", "topic": "v2.0 release planning"})`, + }, + }, + }, + + // ── Swarm (5 actions) ───────────────────────────────────────── + { + Name: "post_task", + Category: "swarm", + Description: "Post a task to an auction channel for agents to bid on", + Params: []Param{ + {Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true}, + {Name: "title", Type: "string", Description: "Task title", Required: true}, + {Name: "description", Type: "string", Description: "Task description"}, + {Name: "requirements", Type: "string", Description: "JSON object of task requirements"}, + {Name: "deadline", Type: "string", Description: "Task deadline in ISO 8601 format (e.g. 2026-03-13T15:00:00Z)"}, + }, + Returns: "JSON with task_id, channel_id, title, status, posted_by, deadline, created_at", + Examples: []Example{ + { + Description: "Post a data analysis task to an auction channel", + Code: `call("post_task", {"channel_name": "task-marketplace", "title": "Analyze Q4 revenue", "description": "Run trend analysis on Q4 revenue data", "deadline": "2026-03-20T17:00:00Z"})`, + }, + }, + }, + { + Name: "bid_task", + Category: "swarm", + Description: "Submit a bid on an open task in an auction channel", + Params: []Param{ + {Name: "task_id", Type: "number", Description: "ID of the task to bid on", Required: true}, + {Name: "capabilities", Type: "string", Description: "JSON object describing your relevant capabilities"}, + {Name: "time_estimate", Type: "string", Description: "Estimated time to complete the task"}, + {Name: "message", Type: "string", Description: "Message to the task poster explaining your bid"}, + }, + Returns: "JSON with bid_id, task_id, agent_name, time_estimate, status", + Examples: []Example{ + { + Description: "Bid on a task with capabilities and time estimate", + Code: `call("bid_task", {"task_id": 7, "capabilities": "{\"skills\": [\"data-analysis\", \"python\"]}", "time_estimate": "2 hours", "message": "I have experience with revenue trend analysis"})`, + }, + }, + }, + { + Name: "accept_bid", + Category: "swarm", + Description: "Accept a bid on a task you posted, assigning the task to the bidding agent", + Params: []Param{ + {Name: "task_id", Type: "number", Description: "ID of the task", Required: true}, + {Name: "bid_id", Type: "number", Description: "ID of the bid to accept", Required: true}, + }, + Returns: "JSON with task_id, bid_id, and status 'accepted'", + Examples: []Example{ + { + Description: "Accept a bid on your task", + Code: `call("accept_bid", {"task_id": 7, "bid_id": 3})`, + }, + }, + }, + { + Name: "complete_task", + Category: "swarm", + Description: "Mark a task as completed (only the assigned agent can do this)", + Params: []Param{ + {Name: "task_id", Type: "number", Description: "ID of the task to complete", Required: true}, + }, + Returns: "JSON with task_id and status 'completed'", + Examples: []Example{ + { + Description: "Mark an assigned task as completed", + Code: `call("complete_task", {"task_id": 7})`, + }, + }, + }, + { + Name: "list_tasks", + Category: "swarm", + Description: "List tasks in an auction channel, optionally filtered by status", + Params: []Param{ + {Name: "channel_name", Type: "string", Description: "Name of the auction channel", Required: true}, + {Name: "status", Type: "string", Description: "Filter by task status: open, assigned, completed, cancelled"}, + }, + Returns: "JSON with tasks array (id, title, description, status, posted_by, assigned_to, deadline, created_at) and count", + Examples: []Example{ + { + Description: "List open tasks in an auction channel", + Code: `call("list_tasks", {"channel_name": "task-marketplace", "status": "open"})`, + }, + }, + }, + + // ── Attachments (2 actions) ─────────────────────────────────── + { + Name: "upload_attachment", + Category: "attachments", + Description: "Upload a file attachment. Content must be base64-encoded. Returns the SHA-256 hash for later retrieval. Max file size: 50MB.", + Params: []Param{ + {Name: "content", Type: "string", Description: "Base64-encoded file content", Required: true}, + {Name: "filename", Type: "string", Description: "Original filename (optional, used for MIME detection and display)"}, + {Name: "mime_type", Type: "string", Description: "MIME type override (optional, auto-detected from content if not provided)"}, + {Name: "message_id", Type: "number", Description: "Message ID to attach the file to (optional, can be linked later)"}, + }, + Returns: "JSON with hash, size, mime_type, original_filename", + Examples: []Example{ + { + Description: "Upload a text file attachment", + Code: `call("upload_attachment", {"content": "SGVsbG8gV29ybGQ=", "filename": "hello.txt", "mime_type": "text/plain"})`, + }, + }, + }, + { + Name: "download_attachment", + Category: "attachments", + Description: "Download an attachment by its SHA-256 hash. Returns base64-encoded content along with filename and MIME type metadata.", + Params: []Param{ + {Name: "hash", Type: "string", Description: "SHA-256 hash of the attachment", Required: true}, + }, + Returns: "JSON with hash, content (base64), original_filename, mime_type, size", + Examples: []Example{ + { + Description: "Download an attachment by hash", + Code: `call("download_attachment", {"hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"})`, + }, + }, + }, + } +} diff --git a/internal/actions/registry_test.go b/internal/actions/registry_test.go new file mode 100644 index 0000000..4a33389 --- /dev/null +++ b/internal/actions/registry_test.go @@ -0,0 +1,127 @@ +package actions + +import ( + "testing" +) + +func TestRegistryHas23Actions(t *testing.T) { + r := NewRegistry() + got := len(r.List()) + if got != 23 { + t.Errorf("expected 23 actions, got %d", got) + } +} + +func TestRegistryCategories(t *testing.T) { + r := NewRegistry() + + tests := []struct { + category string + want int + }{ + {"messaging", 7}, + {"channels", 9}, + {"swarm", 5}, + {"attachments", 2}, + } + + for _, tt := range tests { + t.Run(tt.category, func(t *testing.T) { + got := len(r.ListByCategory(tt.category)) + if got != tt.want { + t.Errorf("category %q: expected %d actions, got %d", tt.category, tt.want, got) + } + }) + } +} + +func TestRegistryGetByName(t *testing.T) { + r := NewRegistry() + + allNames := []string{ + // messaging + "my_status", "send_message", "read_inbox", "claim_messages", "mark_done", "search_messages", "discover_agents", + // channels + "create_channel", "join_channel", "leave_channel", "list_channels", + "invite_to_channel", "kick_from_channel", "get_channel_messages", + "send_channel_message", "update_channel", + // swarm + "post_task", "bid_task", "accept_bid", "complete_task", "list_tasks", + // attachments + "upload_attachment", "download_attachment", + } + + for _, name := range allNames { + t.Run(name, func(t *testing.T) { + a, ok := r.Get(name) + if !ok { + t.Fatalf("action %q not found in registry", name) + } + if a.Name != name { + t.Errorf("expected name %q, got %q", name, a.Name) + } + }) + } +} + +func TestRegistryGetNotFound(t *testing.T) { + r := NewRegistry() + _, ok := r.Get("nonexistent_action") + if ok { + t.Error("expected Get to return false for nonexistent action") + } +} + +func TestRegistryActionsHaveExamples(t *testing.T) { + r := NewRegistry() + for _, a := range r.List() { + t.Run(a.Name, func(t *testing.T) { + if len(a.Examples) == 0 { + t.Errorf("action %q has no examples", a.Name) + } + }) + } +} + +func TestRegistryActionsHaveDescriptions(t *testing.T) { + r := NewRegistry() + for _, a := range r.List() { + t.Run(a.Name, func(t *testing.T) { + if a.Description == "" { + t.Errorf("action %q has empty description", a.Name) + } + }) + } +} + +func TestRegistryActionsHaveReturns(t *testing.T) { + r := NewRegistry() + for _, a := range r.List() { + t.Run(a.Name, func(t *testing.T) { + if a.Returns == "" { + t.Errorf("action %q has empty Returns field", a.Name) + } + }) + } +} + +func TestRegistryListByUnknownCategory(t *testing.T) { + r := NewRegistry() + got := r.ListByCategory("nonexistent") + if len(got) != 0 { + t.Errorf("expected 0 actions for unknown category, got %d", len(got)) + } +} + +func TestRegistryListReturnsCopy(t *testing.T) { + r := NewRegistry() + list1 := r.List() + list2 := r.List() + // Mutating the first list should not affect the second. + if len(list1) > 0 { + list1[0].Name = "mutated" + if list2[0].Name == "mutated" { + t.Error("List() should return a copy, not a reference to internal slice") + } + } +} diff --git a/internal/actions/search.go b/internal/actions/search.go new file mode 100644 index 0000000..fd96a15 --- /dev/null +++ b/internal/actions/search.go @@ -0,0 +1,254 @@ +package actions + +import ( + "math" + "sort" + "strings" + "unicode" +) + +// Index is an in-memory BM25 search index over action documentation. +type Index struct { + actions []Action + docs []document // tokenized documents, one per action + avgDL float64 // average document length + df map[string]int // document frequency per term + n int // total number of documents +} + +// document holds the pre-tokenized content for a single action. +type document struct { + terms []string // all tokens in the document + tf map[string]int // term frequency +} + +// BM25 parameters +const ( + bm25K1 = 1.2 + bm25B = 0.75 +) + +// NewIndex builds a BM25 index from the given actions. +func NewIndex(actions []Action) *Index { + idx := &Index{ + actions: make([]Action, len(actions)), + docs: make([]document, len(actions)), + df: make(map[string]int), + n: len(actions), + } + copy(idx.actions, actions) + + totalLen := 0 + for i, a := range actions { + tokens := tokenizeAction(a) + tf := make(map[string]int, len(tokens)) + for _, t := range tokens { + tf[t]++ + } + idx.docs[i] = document{terms: tokens, tf: tf} + totalLen += len(tokens) + } + + if idx.n > 0 { + idx.avgDL = float64(totalLen) / float64(idx.n) + } + + // Compute document frequency for each term. + for _, doc := range idx.docs { + seen := make(map[string]bool, len(doc.tf)) + for term := range doc.tf { + if !seen[term] { + idx.df[term]++ + seen[term] = true + } + } + } + + return idx +} + +// Search returns actions ranked by BM25 relevance to the query. +// An empty query returns all actions (useful for browsing). +func (idx *Index) Search(query string, limit int) []SearchResult { + if limit <= 0 { + limit = idx.n + } + + // Empty query: return all actions with score 0. + queryTerms := tokenizeQuery(query) + if len(queryTerms) == 0 { + results := make([]SearchResult, len(idx.actions)) + for i, a := range idx.actions { + results[i] = SearchResult{Action: a, Score: 0} + } + if limit < len(results) { + return results[:limit] + } + return results + } + + // Score each document. + type scored struct { + index int + score float64 + } + var candidates []scored + + for i, doc := range idx.docs { + score := idx.bm25Score(doc, queryTerms) + if score > 0 { + candidates = append(candidates, scored{index: i, score: score}) + } + } + + // Sort by score descending. + sort.Slice(candidates, func(a, b int) bool { + return candidates[a].score > candidates[b].score + }) + + if limit < len(candidates) { + candidates = candidates[:limit] + } + + results := make([]SearchResult, len(candidates)) + for i, c := range candidates { + results[i] = SearchResult{ + Action: idx.actions[c.index], + Score: c.score, + } + } + return results +} + +// bm25Score computes the BM25 score for a document against query terms. +func (idx *Index) bm25Score(doc document, queryTerms []string) float64 { + dl := float64(len(doc.terms)) + score := 0.0 + + for _, qt := range queryTerms { + tfVal := float64(doc.tf[qt]) + if tfVal == 0 { + continue + } + + nq := float64(idx.df[qt]) + // IDF: ln((N - n(q) + 0.5) / (n(q) + 0.5) + 1) + idf := math.Log((float64(idx.n)-nq+0.5)/(nq+0.5) + 1) + + // BM25 TF component + num := tfVal * (bm25K1 + 1) + denom := tfVal + bm25K1*(1-bm25B+bm25B*dl/idx.avgDL) + score += idf * num / denom + } + + return score +} + +// tokenizeAction builds a searchable token list from an action's fields. +// The action name is included both as a compound token (e.g. "send_message" +// preserved as-is) and as split tokens. This ensures that an exact-name query +// like "send_message" strongly favours the action with that exact name over +// actions that merely contain the same sub-words. +func tokenizeAction(a Action) []string { + var tokens []string + + // Add the full action name as a compound token (lowercased but not split). + // Repeat to boost exact-name matches. + compound := strings.ToLower(a.Name) + for i := 0; i < 5; i++ { + tokens = append(tokens, compound) + } + + // Also add the split tokens from the name. + for i := 0; i < 3; i++ { + tokens = append(tokens, tokenize(a.Name)...) + } + + // Index remaining fields normally. + var parts []string + parts = append(parts, a.Category) + parts = append(parts, a.Description) + parts = append(parts, a.Returns) + + for _, p := range a.Params { + parts = append(parts, p.Name) + parts = append(parts, p.Description) + } + for _, ex := range a.Examples { + parts = append(parts, ex.Description) + } + + tokens = append(tokens, tokenize(strings.Join(parts, " "))...) + return tokens +} + +// tokenizeQuery produces query tokens. It includes the standard split tokens +// plus any compound tokens (underscore-joined words) found in the original +// query. This allows "send_message" to match the compound name token. +func tokenizeQuery(text string) []string { + tokens := tokenize(text) + + // Also add compound tokens for underscore-joined words in the query. + lower := strings.ToLower(text) + words := strings.Fields(lower) + for _, w := range words { + if strings.Contains(w, "_") { + // The compound token is the full underscore-joined word, lowered. + // Strip non-alphanumeric/underscore chars from edges. + cleaned := strings.TrimFunc(w, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' + }) + if cleaned != "" { + tokens = append(tokens, cleaned) + } + } + } + + return tokens +} + +// tokenize splits text into lowercase tokens, splitting on whitespace and +// punctuation, then applies simple stemming to normalize plurals. +func tokenize(text string) []string { + text = strings.ToLower(text) + words := strings.FieldsFunc(text, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) + var out []string + for _, w := range words { + if len(w) > 0 { + out = append(out, simpleStem(w)) + } + } + return out +} + +// simpleStem applies minimal suffix stripping so that plurals and common +// inflections match their base forms (e.g. "channels" -> "channel", +// "messages" -> "message"). This is intentionally simple — no external +// stemming library is needed for ~23 documents. +func simpleStem(w string) string { + // Order matters: try longer suffixes first. + if strings.HasSuffix(w, "sses") { + // "addresses" -> "address" + return w + } + if strings.HasSuffix(w, "ies") && len(w) > 4 { + return w[:len(w)-3] + "y" + } + if strings.HasSuffix(w, "es") && len(w) > 3 { + base := w[:len(w)-2] + // Only strip -es after s, x, z, ch, sh (English rule). + if strings.HasSuffix(base, "s") || strings.HasSuffix(base, "x") || + strings.HasSuffix(base, "z") || strings.HasSuffix(base, "ch") || + strings.HasSuffix(base, "sh") { + return base + } + // Otherwise strip just the trailing 's'. + return w[:len(w)-1] + } + if strings.HasSuffix(w, "s") && len(w) > 3 && !strings.HasSuffix(w, "ss") { + return w[:len(w)-1] + } + return w +} diff --git a/internal/actions/search_test.go b/internal/actions/search_test.go new file mode 100644 index 0000000..1bb197d --- /dev/null +++ b/internal/actions/search_test.go @@ -0,0 +1,209 @@ +package actions + +import ( + "testing" +) + +func TestSearchRelevantResults(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + tests := []struct { + name string + query string + wantNames []string // expected action names in results (order-independent) + }{ + { + name: "send message finds send_message and send_channel_message", + query: "send message", + wantNames: []string{"send_message", "send_channel_message"}, + }, + { + name: "inbox finds read_inbox", + query: "inbox", + wantNames: []string{"read_inbox"}, + }, + { + name: "channel finds channel actions", + query: "channel", + wantNames: []string{"create_channel", "join_channel", "leave_channel", "list_channels"}, + }, + { + name: "task finds swarm actions", + query: "task", + wantNames: []string{"post_task", "bid_task", "complete_task", "list_tasks"}, + }, + { + name: "attachment finds attachment actions", + query: "attachment", + wantNames: []string{"upload_attachment", "download_attachment"}, + }, + { + name: "bid finds bid_task", + query: "bid", + wantNames: []string{"bid_task", "accept_bid"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := idx.Search(tt.query, 0) + resultNames := make(map[string]bool, len(results)) + for _, r := range results { + resultNames[r.Action.Name] = true + } + for _, want := range tt.wantNames { + if !resultNames[want] { + t.Errorf("query %q: expected %q in results, got %v", tt.query, want, nameList(results)) + } + } + }) + } +} + +func TestSearchExactNameRanksHigher(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + // "send_message" as a query should rank send_message above send_channel_message + results := idx.Search("send_message", 0) + if len(results) < 2 { + t.Fatalf("expected at least 2 results, got %d", len(results)) + } + + // The top result should be send_message (exact name match). + if results[0].Action.Name != "send_message" { + t.Errorf("expected send_message as top result, got %q", results[0].Action.Name) + } + + // Verify score ordering. + if results[0].Score <= results[1].Score { + t.Errorf("expected top result score (%f) > second result score (%f)", + results[0].Score, results[1].Score) + } +} + +func TestSearchEmptyQueryReturnsAll(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + results := idx.Search("", 0) + if len(results) != 23 { + t.Errorf("empty query: expected 23 results, got %d", len(results)) + } + + // All scores should be 0 for empty query. + for _, res := range results { + if res.Score != 0 { + t.Errorf("empty query: expected score 0 for %q, got %f", res.Action.Name, res.Score) + } + } +} + +func TestSearchLimitRespected(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + tests := []struct { + query string + limit int + }{ + {"message", 1}, + {"channel", 3}, + {"", 5}, + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + results := idx.Search(tt.query, tt.limit) + if len(results) > tt.limit { + t.Errorf("query %q limit %d: got %d results", tt.query, tt.limit, len(results)) + } + }) + } +} + +func TestSearchNoResults(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + results := idx.Search("xyzzyplugh", 10) + if len(results) != 0 { + t.Errorf("nonsense query: expected 0 results, got %d", len(results)) + } +} + +func TestSearchScoresPositive(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + results := idx.Search("send message agent", 10) + for _, res := range results { + if res.Score <= 0 { + t.Errorf("non-empty query matched %q with non-positive score %f", + res.Action.Name, res.Score) + } + } +} + +func TestSearchDescending(t *testing.T) { + r := NewRegistry() + idx := NewIndex(r.List()) + + results := idx.Search("message", 0) + for i := 1; i < len(results); i++ { + if results[i].Score > results[i-1].Score { + t.Errorf("results not sorted descending: score[%d]=%f > score[%d]=%f", + i, results[i].Score, i-1, results[i-1].Score) + } + } +} + +func TestTokenize(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"send_message", []string{"send", "message"}}, + {"Hello, World!", []string{"hello", "world"}}, + {"JSON metadata object (optional)", []string{"json", "metadata", "object", "optional"}}, + {"", nil}, + {" spaces ", []string{"space"}}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := tokenize(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("tokenize(%q): got %v, want %v", tt.input, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("tokenize(%q)[%d]: got %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestNewIndexEmpty(t *testing.T) { + idx := NewIndex(nil) + results := idx.Search("anything", 10) + if len(results) != 0 { + t.Errorf("empty index: expected 0 results, got %d", len(results)) + } + + results = idx.Search("", 10) + if len(results) != 0 { + t.Errorf("empty index empty query: expected 0 results, got %d", len(results)) + } +} + +// nameList is a test helper that extracts action names from search results. +func nameList(results []SearchResult) []string { + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.Action.Name + } + return names +} diff --git a/internal/actions/types.go b/internal/actions/types.go new file mode 100644 index 0000000..1c8759b --- /dev/null +++ b/internal/actions/types.go @@ -0,0 +1,32 @@ +package actions + +// Action represents a callable operation in the system. +type Action struct { + Name string `json:"name"` + Category string `json:"category"` // messaging, channels, swarm, attachments + Description string `json:"description"` + Params []Param `json:"params"` + Returns string `json:"returns"` // Human-readable return description + Examples []Example `json:"examples"` +} + +// Param describes an action parameter. +type Param struct { + Name string `json:"name"` + Type string `json:"type"` // string, number, boolean + Description string `json:"description"` + Required bool `json:"required"` + Default string `json:"default,omitempty"` +} + +// Example shows a usage example for the action. +type Example struct { + Description string `json:"description"` + Code string `json:"code"` // JS code example using call() +} + +// SearchResult is an action with a relevance score. +type SearchResult struct { + Action Action `json:"action"` + Score float64 `json:"score"` +}