171 lines
6.3 KiB
Go
171 lines
6.3 KiB
Go
// This file implements the memory_search AgentTool (issue #477): a read-only
|
|
// tool that queries the persistent memory library (internal/memory) by relevance
|
|
// using its BM25 full-text index and returns ranked snippets to the model.
|
|
//
|
|
// Writes are intentionally NOT a separate tool here. Per the SPEC (§4.1/§4.2),
|
|
// memory writes reuse the existing Write/Edit file tools, constrained to the
|
|
// memory root and carrying the canonical frontmatter (name/description/
|
|
// metadata.type). Traversal protection for those writes lives in the memory
|
|
// package (memory.assertSafeComponent) and the write-path plumbing; a dedicated
|
|
// memory_write tool would duplicate that. After an off-tool write, the next
|
|
// memory_search picks it up automatically because Execute searches with
|
|
// ReconcileFirst=true (lazy reconcile).
|
|
package agenttool
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/memory"
|
|
)
|
|
|
|
// memorySearchDefaultLimit is the result cap used when the caller omits limit or
|
|
// passes a non-positive value; memorySearchMaxLimit is the hard upper bound.
|
|
const (
|
|
memorySearchDefaultLimit = 10
|
|
memorySearchMaxLimit = 50
|
|
)
|
|
|
|
// MemorySearchTool searches the persistent memory library by relevance. Store is
|
|
// exported so the loop-integration node (#481) can construct the tool with a
|
|
// live *memory.Store, mirroring how TodoTool exposes its Store.
|
|
type MemorySearchTool struct {
|
|
// Store is the persistent memory store. When nil, Execute degrades to a
|
|
// friendly no-op result rather than erroring, so a session without memory
|
|
// configured still runs.
|
|
Store *memory.Store
|
|
}
|
|
|
|
// memorySearchArgs is the decoded argument shape for memory_search.
|
|
type memorySearchArgs struct {
|
|
Query string `json:"query"`
|
|
Scope string `json:"scope"`
|
|
Type string `json:"type"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
// Name implements AgentTool.
|
|
func (t *MemorySearchTool) Name() string { return "memory_search" }
|
|
|
|
// Description implements AgentTool.
|
|
func (t *MemorySearchTool) Description() string {
|
|
return "Search the persistent memory library by relevance (BM25 full-text) " +
|
|
"and return ranked snippets from previously saved notes, checkpoints, and " +
|
|
"references. Use it to recall context from earlier sessions before " +
|
|
"answering or acting. Optional filters: scope (global|projects|sessions|cc), " +
|
|
"type (user|feedback|project|reference|checkpoint|progress|notes|free), and " +
|
|
"limit (default 10, max 50). Results are ordered most-relevant first."
|
|
}
|
|
|
|
// Schema implements AgentTool.
|
|
func (t *MemorySearchTool) Schema() json.RawMessage {
|
|
return json.RawMessage(`{
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Free-text query; tokenized and matched against memory bodies (BM25)."},
|
|
"scope": {"type": "string", "description": "Optional scope filter: global, projects, sessions, or cc."},
|
|
"type": {"type": "string", "description": "Optional type filter: user, feedback, project, reference, checkpoint, progress, notes, or free."},
|
|
"limit": {"type": "integer", "description": "Max results to return (default 10, capped at 50)."}
|
|
},
|
|
"required": ["query"],
|
|
"additionalProperties": false
|
|
}`)
|
|
}
|
|
|
|
// ExecutionMode implements AgentTool. memory_search is read-only, so it runs in
|
|
// the default parallel mode.
|
|
func (t *MemorySearchTool) ExecutionMode() agentcore.ToolExecutionMode {
|
|
return agentcore.ToolExecutionParallel
|
|
}
|
|
|
|
// Execute implements AgentTool. It decodes the args, runs a lazily-reconciled
|
|
// BM25 search, and formats the ranked hits as text (one line each:
|
|
// "[type/scope] path (score) — snippet") with the structured []SearchResult in
|
|
// Details. A nil Store or empty query degrades to a friendly no-op result rather
|
|
// than a Go error so the loop keeps running.
|
|
func (t *MemorySearchTool) Execute(ctx context.Context, id string, args json.RawMessage, onUpdate agentcore.ToolUpdateFunc) (agentcore.AgentToolResult, error) {
|
|
a, bad := decodeArgs[memorySearchArgs](args, "memory_search")
|
|
if bad != nil {
|
|
return *bad, nil
|
|
}
|
|
|
|
query := strings.TrimSpace(a.Query)
|
|
if t.Store == nil {
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(
|
|
"memory_search: no memory store configured; nothing to search.")},
|
|
}, nil
|
|
}
|
|
if query == "" {
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(
|
|
"memory_search: empty query; provide a search string.")},
|
|
}, nil
|
|
}
|
|
|
|
limit := a.Limit
|
|
if limit <= 0 {
|
|
limit = memorySearchDefaultLimit
|
|
}
|
|
if limit > memorySearchMaxLimit {
|
|
limit = memorySearchMaxLimit
|
|
}
|
|
|
|
results, err := t.Store.Search(query, memory.SearchOptions{
|
|
Scope: strings.TrimSpace(a.Scope),
|
|
Type: strings.TrimSpace(a.Type),
|
|
Limit: limit,
|
|
ReconcileFirst: true, // lazy reconcile so off-tool writes are indexed
|
|
// ScoreFloor left at its zero value → package default (0.15).
|
|
})
|
|
if err != nil {
|
|
return errorResult(fmt.Sprintf("memory_search: %v", err)), nil
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(
|
|
fmt.Sprintf("memory_search: no results for %q.", query))},
|
|
Details: results,
|
|
}, nil
|
|
}
|
|
|
|
return agentcore.AgentToolResult{
|
|
Content: agentcore.ContentList{agentcore.NewTextContent(formatMemoryResults(query, results))},
|
|
Details: results,
|
|
}, nil
|
|
}
|
|
|
|
// formatMemoryResults renders ranked hits as a header line plus one line per
|
|
// result: "[type/scope] path (score) — snippet". Snippets are whitespace-
|
|
// collapsed so a multi-line body stays on a single row.
|
|
func formatMemoryResults(query string, results []memory.SearchResult) string {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "memory_search: %d result(s) for %q, most relevant first:", len(results), query)
|
|
for _, r := range results {
|
|
typ := string(r.Type)
|
|
if typ == "" {
|
|
typ = "free"
|
|
}
|
|
scope := string(r.Scope)
|
|
if r.ScopeID != "" {
|
|
scope = scope + "/" + r.ScopeID
|
|
}
|
|
line := fmt.Sprintf("\n[%s/%s] %s (%.3f)", typ, scope, r.Path, r.Score)
|
|
if snip := collapseWhitespace(r.Snippet); snip != "" {
|
|
line += " — " + snip
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// collapseWhitespace folds any run of whitespace (including newlines) into a
|
|
// single space and trims the ends, keeping a snippet to one line.
|
|
func collapseWhitespace(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|