Files
BlackBean/pigo/internal/agenttool/memory_tool.go
T
2026-08-14 23:41:57 +08:00

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), " ")
}