Files
steve 78e6858751 P3: store group — kv_* + file_* tools (agent memory)
RegisterStore(reg, StoreDeps) registers the persistent-memory tools over the
host's KV and/or File backends:
- kv_get/set/list/delete (KVStorage seam)
- file_save/get/get_text/get_metadata/list/delete (FileStorage seam), plus
  file_search (FileSearcher) and create_file_url (FileTokenMinter) when wired.

Near-zero-config: Quota defaults to a generous static cap (staticQuota), the
per-value/per-file caps default, and the kv vs file groups register
independently (a host can take just one). Seams moved clean (interface-only):
kv_storage.go, quota_provider.go, file_descendant_grant.go. The default
in-memory KV/File backends come with contrib/store at P4.

Core go.sum still free of gorm/redis/discordgo/sqlite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:11:54 -04:00

90 lines
3.7 KiB
Go

// kv_storage.go declares the narrow KV-storage interface that the four
// KV tools (kv_get, kv_set, kv_list, kv_delete) need at execute time.
//
// Why a narrow interface (vs importing pkg/logic/skills directly):
// pkg/logic/skills imports pkg/skilltools (for Invocation + Tool), so
// importing skills back here would form an import cycle. Production
// wiring (pkg/logic/mort.go, deferred) will supply a concrete adapter
// that wraps `*skills.System.Storage()` and translates between
// skills.KVEntry and the local KVDomainEntry shape.
//
// Why a *separate* domain shape (KVDomainEntry) vs reusing skills.KVEntry:
// the cycle break has to be complete — even importing the type would
// pull skills into skilltools/tools' import graph. The two shapes mirror
// each other field-for-field; the adapter is a trivial struct copy.
//
// The same pattern is used by skill_invoke.go (SkillInvokerProvider).
package tools
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// kvPartition picks the skill_id partition for a KV operation. KV rows
// are keyed (skill_id, scope, key); for the shared `root_run:<id>`
// scope, every run in a dispatch tree — including ephemeral workers
// with distinct agent IDs — must land in ONE partition or siblings
// could never see each other's writes. The sentinel
// tool.RootRunKVPartition is that shared partition; isolation
// between trees is preserved because the scope string embeds the root
// run id, which ValidateScope checks against inv.RootRunID.
func kvPartition(inv tool.Invocation, scope string) string {
if strings.HasPrefix(scope, "root_run:") {
return tool.RootRunKVPartition
}
return inv.SkillID
}
// KVStorage is the narrow surface KV tools need from the skills package.
// nil-safe: tools constructed against a nil KVStorage surface a clean
// "not configured" error at the first call rather than crashing.
type KVStorage interface {
KVGet(ctx context.Context, skillID, scope, key string) (*KVDomainEntry, error)
KVSet(ctx context.Context, e KVDomainEntry) error
KVList(ctx context.Context, skillID, scope, prefix string, limit int) ([]KVDomainEntry, error)
KVDelete(ctx context.Context, skillID, scope, key string) error
KVUsageBytes(ctx context.Context, skillID string) (int64, error)
}
// KVHistoryRecorder is the OPTIONAL post-write hook for the v7
// versioned KV history. The kv_set tool checks for this interface via
// type assertion; production storage adapters that satisfy it write a
// history row AFTER a successful KVSet.
//
// Why optional (vs adding to KVStorage): existing test fakes don't
// need to grow a method. Production wires the real adapter which
// satisfies the interface; tests that don't care about history skip
// the implementation entirely.
//
// Why only on success: a failed KVSet leaves no skill_kv row to refer
// to; appending a history entry would create an orphan record of a
// change that didn't happen.
type KVHistoryRecorder interface {
RecordKVHistory(ctx context.Context, skillID, scope, key string, value []byte, changedBy string) error
}
// KVDomainEntry mirrors skills.KVEntry without pulling in the cycle.
// Field-for-field with the skills package's KVEntry; the production
// adapter is a struct copy.
type KVDomainEntry struct {
SkillID string
Scope string // "skill" | "user:<id>" | "run:<id>"
Key string
Value json.RawMessage
ExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// ErrKVNotFound mirrors skills.ErrKVNotFound. The production adapter
// returns this sentinel when wrapping a skills.ErrKVNotFound; tools
// detect it with errors.Is to surface "not_found" to the LLM rather
// than a generic error.
var ErrKVNotFound = errors.New("kv: not found")