// 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:` // 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:" | "run:" 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")