P3: store group — kv_* + file_* tools (agent memory)
executus CI / test (pull_request) Successful in 58s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m10s

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>
This commit is contained in:
2026-06-26 22:06:46 -04:00
parent 89f3334512
commit ac961e1539
17 changed files with 1565 additions and 5 deletions
+6 -5
View File
@@ -60,11 +60,12 @@ CORE (majordomo + stdlib):
compact/ context compactor (WithCompactor hook) [P2 ✓]
tools/ generic tool library: Register (think/now/ [P3 wip]
cite, zero-config) + RegisterMeta (classify/
extract_entities/summarize); seams in
research_providers.go/file_storage.go;
in-memory budget default. End-to-end "agent
calls a tool" test green. Remaining: web/net/
store/compose groups + their backends [P3]
extract_entities/summarize) + RegisterStore
(kv_*/file_*, default static quota); seams in
research_providers.go/file_storage.go/
kv_storage.go/quota_provider.go. End-to-end
"agent calls a tool" test green. Remaining:
web/net/compose groups + default backends [P3]
BATTERIES (opt-in siblings, each nil-safe + a default):
persona/ Agent noun + AgentStore seam + yml loader [P4]
+196
View File
@@ -0,0 +1,196 @@
// create_file_url mints a public-token URL (mort.sh/files/<token>)
// that resolves to a saved file_id. Use it for artifacts that are too
// large for Discord (>25 MiB), need a stable link to share outside
// Discord, or where the recipient is not in mort's auth domain.
//
// Why a separate tool (vs always returning a URL from file_save):
// most files are private working state — only some need a public URL,
// and minting one is a deliberate act. Decoupling save from
// publication keeps the storage layer cheap (no token row per file)
// and the audit clean (you can grep skill_file_tokens for "who
// published what").
//
// Cycle-break: this tool can't import pkg/logic/skills directly
// (pkg/logic/skills imports pkg/skilltools). The narrow interface
// FileTokenMinter is declared here; mort.go bridges to
// *skills.System.Storage() at wiring time.
package tools
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// FileToken is the wire-shape of the storage row that backs the
// public /files/<token> URL. Mirrors pkg/logic/skills.FileToken
// field-for-field; the adapter in mort.go is a struct copy.
//
// Why mirror (vs import skills.FileToken): same cycle constraint as
// FileDomainMeta / KVDomainEntry — the tool layer cannot import
// pkg/logic/skills.
type FileToken struct {
Token string
FileID string
SkillID string
CallerID string
CreatedAt time.Time
ExpiresAt *time.Time
MaxViews *int
Views int
}
// FileTokenMinter is the narrow interface the create_file_url tool
// needs to persist a new token. Production wires to
// *skills.gormStorage via a thin adapter in mort.go.
type FileTokenMinter interface {
SaveFileToken(ctx context.Context, t FileToken) error
}
// Caps for create_file_url. Public so tests can assert against them.
const (
// DefaultFileURLExpiry is the default lifetime applied when the
// caller doesn't supply expires_in_seconds.
DefaultFileURLExpiry = 24 * time.Hour
// MaxFileURLExpiry is the per-tool hard cap. 30 days is generous
// enough for "share this report with someone" without becoming
// effectively-permanent. Operators can lower via the
// SkillFileURLConfigProvider; this is the floor below which the
// admin gate doesn't apply.
MaxFileURLExpiry = 30 * 24 * time.Hour
// MaxFileURLViews is the per-tool hard cap on max_views. 1000 is
// the largest value an LLM might plausibly set; anything beyond
// is "unlimited" semantically and the caller should leave the
// field absent.
MaxFileURLViews = 1000
)
type createFileURLArgs struct {
FileID string `json:"file_id" description:"file_id previously saved by this skill (from file_save, code_exec, etc)."`
ExpiresInSeconds int `json:"expires_in_seconds,omitempty" description:"How long the URL stays valid in seconds. Default 86400 (24h). Max 2592000 (30 days)."`
MaxViews int `json:"max_views,omitempty" description:"Optional cap on the number of times the URL can be fetched. Max 1000. Omit (or 0) for unlimited within the lifetime."`
}
type createFileURLResult struct {
URL string `json:"url"`
Token string `json:"token"`
ExpiresAt string `json:"expires_at,omitempty"` // RFC3339
MaxViews int `json:"max_views,omitempty"`
Note string `json:"note,omitempty"`
}
// NewCreateFileURL constructs the create_file_url tool. nil minter →
// "not configured" at execute time; nil fileStorage same. baseURL is
// the public site (e.g. "https://mort.sh"); the path "/files/<token>"
// is appended.
//
// Permission shape: anyone-authoring + caller-scope + share-safe +
// files/discord/composition. The "publishing" act is a tool call,
// not a save-time / share-time concern — every caller of a shared
// skill mints into their own audit trail.
func NewCreateFileURL(minter FileTokenMinter, fileStorage FileStorage, baseURL string) tool.Tool {
baseURL = strings.TrimRight(baseURL, "/")
return tool.NewGatedTool[createFileURLArgs](
"create_file_url",
"Mint a public URL (mort.sh/files/<token>) for a saved file_id. Use for files too large for Discord (>25 MiB) or when a stable link is preferred over an attachment. Default expiry 24h; max 30 days. Optional view-count cap (max 1000).",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"files", "discord"},
},
func(ctx context.Context, inv tool.Invocation, args createFileURLArgs) (string, error) {
if minter == nil || fileStorage == nil {
return "", fmt.Errorf("create_file_url: not configured")
}
if strings.TrimSpace(args.FileID) == "" {
return "", fmt.Errorf("create_file_url: file_id required")
}
// Cross-skill rejection: the file MUST belong to the
// calling skill. Without this, a hostile skill could mint
// a URL for ANY file by file_id.
meta, _, err := fileStorage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "", fmt.Errorf("create_file_url: file_id %q not found", args.FileID)
}
return "", fmt.Errorf("create_file_url: %w", err)
}
if meta.SkillID != inv.SkillID && !descendantFileGrant(ctx, fileStorage, inv, meta.SkillID) {
return "", fmt.Errorf("create_file_url: file_id %q does not belong to this skill (cross-skill refs rejected)", args.FileID)
}
// Resolve expiry.
expiry := DefaultFileURLExpiry
if args.ExpiresInSeconds > 0 {
expiry = time.Duration(args.ExpiresInSeconds) * time.Second
}
if expiry > MaxFileURLExpiry {
expiry = MaxFileURLExpiry
}
expiresAt := time.Now().Add(expiry)
// Resolve max_views.
var maxViews *int
if args.MaxViews > 0 {
mv := args.MaxViews
if mv > MaxFileURLViews {
mv = MaxFileURLViews
}
maxViews = &mv
}
// Mint a 32-byte random token, base64url-encoded
// (padless). 43 chars long; the storage column is 64 so
// there's room to grow without a migration.
token, err := mintFileURLToken()
if err != nil {
return "", fmt.Errorf("create_file_url: token generation: %w", err)
}
// Persist.
if err := minter.SaveFileToken(ctx, FileToken{
Token: token,
FileID: args.FileID,
SkillID: inv.SkillID,
CallerID: inv.CallerID,
ExpiresAt: &expiresAt,
MaxViews: maxViews,
}); err != nil {
return "", fmt.Errorf("create_file_url: save: %w", err)
}
url := baseURL + "/files/" + token
res := createFileURLResult{
URL: url,
Token: token,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
Note: "URL is public — anyone with the link can fetch this file until it expires or the view cap is reached.",
}
if maxViews != nil {
res.MaxViews = *maxViews
}
b, _ := json.Marshal(res)
return string(b), nil
},
)
}
// mintFileURLToken returns a 32-byte random token, base64url-encoded
// without padding. ~190 bits of entropy, well above the
// collision-resistance threshold for the 64-char storage column.
func mintFileURLToken() (string, error) {
var b [32]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
+70
View File
@@ -0,0 +1,70 @@
// file_delete removes a saved file by its file_id. Decrements the
// underlying blob's refcount in storage; the blob row is removed when
// refcount hits zero.
//
// Why scope is checked POST-fetch (mirrors file_get): file_id is the
// only key the caller has; we must read the row to know the scope.
package tools
import (
"context"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type fileDeleteArgs struct {
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
}
// NewFileDelete constructs the file_delete tool. storage nil → "not
// configured" at execute time.
func NewFileDelete(storage FileStorage) tool.Tool {
return tool.NewGatedTool[fileDeleteArgs](
"file_delete",
"Remove a saved file by file_id. Returns 'ok' on success or 'not_found' if no file matched.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "write"},
},
func(ctx context.Context, inv tool.Invocation, args fileDeleteArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_delete: not configured")
}
if args.FileID == "" {
return "", fmt.Errorf("file_delete: file_id required")
}
// Fetch first so we can validate scope before deleting. The
// extra read is acceptable for a write path that's not in
// the hot loop, and it preserves the cross-skill /
// cross-user safety story.
meta, _, err := storage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "not_found", nil
}
return "", fmt.Errorf("file_delete: %w", err)
}
if meta.SkillID != inv.SkillID {
return "", fmt.Errorf("file_delete: file does not belong to this skill")
}
if err := ValidateScope(inv, meta.Scope, false); err != nil {
return "", fmt.Errorf("file_delete: %w", err)
}
if err := storage.FileDelete(ctx, args.FileID); err != nil {
if errors.Is(err, ErrFileNotFound) {
// Race: row was deleted between FileGet and
// FileDelete. Surface as a clean miss.
return "not_found", nil
}
return "", fmt.Errorf("file_delete: %w", err)
}
return "ok", nil
},
)
}
+58
View File
@@ -0,0 +1,58 @@
// file_descendant_grant.go — the cross-skill file-access escape hatch
// for parent → spawned-worker handoff.
//
// The blanket rule everywhere in this package is "a file belongs to
// the skill that saved it; cross-skill refs are rejected". That rule
// breaks the agent_spawn flow: a worker saves a chart with file_save
// under ITS ephemeral ID, returns the file_id as text, and the parent
// (which orchestrated the whole thing) can't attach, read, or host it.
// Observed live on the second spawn test — the chart never reached
// Discord; general could only apologise with the file_id.
//
// The grant: a caller may access a file whose owning skill/agent
// PRODUCED A RUN THAT DESCENDS FROM THE CALLER'S CURRENT RUN. In other
// words: you may touch the artifacts of workers you (transitively)
// dispatched in this very tree — output you were already entitled to
// see as their tool results. You may NOT touch files from siblings,
// ancestors, other trees, or unrelated skills; those still reject.
//
// Why an optional interface upgrade (vs a new constructor dep on
// every file tool): six tools enforce the ownership rule, each with
// its own narrow storage interface — threading a new dep through all
// of them churns every signature and test fake. Instead, the
// production storage adapter (mort.go's skillsFileStorageAdapter,
// which backs ALL of those interfaces) additionally implements
// DescendantRunChecker; the tools type-assert at the rejection site.
// Fakes that don't implement it keep the strict behaviour — the grant
// is fail-closed everywhere. Same pattern as KVHistoryRecorder (v7).
package tools
import (
"context"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// DescendantRunChecker reports whether ownerSkillID (the file's owning
// skill or agent ID — e.g. a spawned worker's "eph-…" ID) produced a
// run that is a DESCENDANT of callerRunID. Production walks the audit
// parent_run_id chain; see mort_skills_storage_adapters.go.
type DescendantRunChecker interface {
IsDescendantProducer(ctx context.Context, ownerSkillID, callerRunID string) (bool, error)
}
// descendantFileGrant is called at a cross-skill rejection site with
// the tool's storage dep. Returns true only when the dep implements
// DescendantRunChecker AND the owner's run descends from the caller's
// run. Any error or missing context keeps the strict rejection.
func descendantFileGrant(ctx context.Context, dep any, inv tool.Invocation, ownerSkillID string) bool {
if ownerSkillID == "" || inv.RunID == "" {
return false
}
checker, ok := dep.(DescendantRunChecker)
if !ok || checker == nil {
return false
}
granted, err := checker.IsDescendantProducer(ctx, ownerSkillID, inv.RunID)
return err == nil && granted
}
+103
View File
@@ -0,0 +1,103 @@
// file_get fetches a previously-saved file by its opaque file_id and
// returns the metadata + base64-encoded bytes.
//
// Why scope is checked POST-fetch: file_id is the only key the caller
// knows; the scope (and therefore the authorisation envelope) is
// stored on the FileMeta row. We must read the row first to know which
// scope to validate. The trade-off is that file_id existence is
// observable (a foreign caller can probe IDs and learn that one
// exists), but the bytes themselves are still gated. file_id is a UUID,
// so the probe surface is impractical.
//
// Why base64 in the response: same reason as file_save — JSON can't
// carry arbitrary bytes natively. Callers that want a paste link or a
// direct download go through a separate path.
package tools
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type fileGetArgs struct {
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
}
type fileGetResult struct {
Name string `json:"name"`
ContentBase64 string `json:"content_base64"`
Mime string `json:"mime"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"` // RFC3339
}
// NewFileGet constructs the file_get tool. storage nil → "not
// configured" at execute time.
func NewFileGet(storage FileStorage) tool.Tool {
return tool.NewGatedTool[fileGetArgs](
"file_get",
"Fetch a saved file by its file_id. Returns name, base64 content, MIME, size, and created_at. The caller must have access to the file's scope (skill / own user: / own run:).",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args fileGetArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_get: not configured")
}
if args.FileID == "" {
return "", fmt.Errorf("file_get: file_id required")
}
meta, content, err := storage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "", fmt.Errorf("file_get: not found")
}
return "", fmt.Errorf("file_get: %w", err)
}
// Cross-skill access check: a file's SkillID must match the
// current invocation's SkillID. Without this, a caller
// could probe another skill's file_ids and read content.
// One exception — the descendant grant (see
// file_descendant_grant.go): workers this run dispatched.
grantedViaDescendant := false
if meta.SkillID != inv.SkillID {
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
return "", fmt.Errorf("file_get: file does not belong to this skill")
}
grantedViaDescendant = true
}
// Scope check: even within the same skill, the scope on the
// row gates access (e.g. user:bob's file is unreadable by
// alice). The descendant grant stands in for it — the file's
// scope is the WORKER's run, never the caller's.
if err := ValidateScope(inv, meta.Scope, false); err != nil && !grantedViaDescendant {
return "", fmt.Errorf("file_get: %w", err)
}
res := fileGetResult{
Name: meta.Name,
ContentBase64: base64.StdEncoding.EncodeToString(content),
Mime: meta.MimeType,
SizeBytes: meta.SizeBytes,
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
}
b, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("file_get: marshal: %w", err)
}
return string(b), nil
},
)
}
+91
View File
@@ -0,0 +1,91 @@
// file_get_metadata returns metadata about a saved file (name, mime,
// size, created_at) WITHOUT loading the bytes. This is the v10
// agent-friendly companion to file_get — agents that just need to
// reason about a file's properties (size, type, name) should use
// file_get_metadata instead of pulling the full body into the context
// window.
//
// Why a separate tool (vs adding a flag to file_get): the byte-vs-
// reference principle is enforced statically — file_get_metadata's
// return shape simply does not carry bytes, so agents and tool
// authors can rely on the type signature. A flag-gated variant would
// invite "what does include_content=false mean" confusion.
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type fileGetMetadataArgs struct {
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
}
type fileGetMetadataResult struct {
Name string `json:"name"`
Mime string `json:"mime"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"` // RFC3339
Scope string `json:"scope"`
}
// NewFileGetMetadata constructs the file_get_metadata tool. storage
// nil → "not configured" at execute time.
func NewFileGetMetadata(storage FileStorage) tool.Tool {
return tool.NewGatedTool[fileGetMetadataArgs](
"file_get_metadata",
"Fetch metadata for a saved file by its file_id (name, mime, size_bytes, created_at, scope). Does NOT load the file bytes — use file_get_text for text content or send_attachments to ship binary content to Discord.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args fileGetMetadataArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_get_metadata: not configured")
}
if args.FileID == "" {
return "", fmt.Errorf("file_get_metadata: file_id required")
}
meta, _, err := storage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "", fmt.Errorf("file_get_metadata: not found")
}
return "", fmt.Errorf("file_get_metadata: %w", err)
}
// Descendant grant: see file_descendant_grant.go — covers
// the scope check too (the file's scope is the worker's run).
grantedViaDescendant := false
if meta.SkillID != inv.SkillID {
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
return "", fmt.Errorf("file_get_metadata: file does not belong to this skill")
}
grantedViaDescendant = true
}
if !grantedViaDescendant {
if err := ValidateScope(inv, meta.Scope, false); err != nil {
return "", fmt.Errorf("file_get_metadata: %w", err)
}
}
res := fileGetMetadataResult{
Name: meta.Name,
Mime: meta.MimeType,
SizeBytes: meta.SizeBytes,
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
Scope: meta.Scope,
}
b, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("file_get_metadata: marshal: %w", err)
}
return string(b), nil
},
)
}
+119
View File
@@ -0,0 +1,119 @@
// file_get_text fetches a saved text file's content as plain text.
// Only succeeds for text/* MIMEs; binary MIMEs return an error so the
// agent knows to use a different path (file_get_metadata for
// reasoning, send_attachments for delivery).
//
// Why a 64 KiB cap: the v10 byte-vs-reference principle says inline
// text content stays under ~10KB ideally; we set the hard cap at 64
// KiB to handle reasonable text artifacts (logs, configs, small
// reports) without blowing the agent's context. Files larger than
// the cap return an error pointing to send_attachments.
//
// Why a separate tool (vs file_get): file_get returns base64 +
// metadata regardless of MIME, which agents misuse to dump 10MB PDFs
// into the context window. file_get_text is the agent-friendly
// alternative that explicitly fails fast on binary content.
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
const fileGetTextMaxBytes = 64 * 1024
type fileGetTextArgs struct {
FileID string `json:"file_id" description:"Opaque file ID returned by file_save or file_list."`
}
type fileGetTextResult struct {
Text string `json:"text"`
Mime string `json:"mime"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"` // RFC3339
}
// NewFileGetText constructs the file_get_text tool. storage nil →
// "not configured" at execute time.
func NewFileGetText(storage FileStorage) tool.Tool {
return tool.NewGatedTool[fileGetTextArgs](
"file_get_text",
"Fetch a saved text file's content (text/* MIMEs only, capped at 64KB). For binary content use file_get_metadata + send_attachments. Errors with 'not_text' for non-text MIMEs and 'too_large' for files > 64KB.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args fileGetTextArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_get_text: not configured")
}
if args.FileID == "" {
return "", fmt.Errorf("file_get_text: file_id required")
}
meta, content, err := storage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "", fmt.Errorf("file_get_text: not found")
}
return "", fmt.Errorf("file_get_text: %w", err)
}
// Descendant grant: a worker this run (transitively)
// dispatched may have produced the file — its scope is the
// WORKER's run, so the grant also stands in for the scope
// check below.
grantedViaDescendant := false
if meta.SkillID != inv.SkillID {
if !descendantFileGrant(ctx, storage, inv, meta.SkillID) {
return "", fmt.Errorf("file_get_text: file does not belong to this skill")
}
grantedViaDescendant = true
}
if !grantedViaDescendant {
if err := ValidateScope(inv, meta.Scope, false); err != nil {
return "", fmt.Errorf("file_get_text: %w", err)
}
}
if !isTextMime(meta.MimeType) {
return "", fmt.Errorf("file_get_text: not_text: mime %q is not text/*", meta.MimeType)
}
if int64(len(content)) > fileGetTextMaxBytes {
return "", fmt.Errorf("file_get_text: too_large: %d bytes exceeds 64KB cap; use send_attachments to deliver this file to Discord", len(content))
}
res := fileGetTextResult{
Text: string(content),
Mime: meta.MimeType,
SizeBytes: meta.SizeBytes,
CreatedAt: meta.CreatedAt.UTC().Format(time.RFC3339),
}
b, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("file_get_text: marshal: %w", err)
}
return string(b), nil
},
)
}
// isTextMime reports whether the given MIME is a text/* type.
// Accepts "text/plain", "text/markdown", "text/csv", "application/json"
// and "application/xml" since those are conventionally text.
func isTextMime(mime string) bool {
mime = strings.ToLower(strings.TrimSpace(mime))
if strings.HasPrefix(mime, "text/") {
return true
}
switch mime {
case "application/json", "application/xml", "application/xhtml+xml",
"application/javascript", "application/yaml", "application/x-yaml":
return true
}
return false
}
+74
View File
@@ -0,0 +1,74 @@
// file_list returns metadata for files in a scope. Blob bytes are NOT
// loaded — listing is a hot path that must stay light, and the LLM
// would burn tokens for no benefit.
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type fileListArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', or 'run:<run_id>'."`
}
type fileListEntry struct {
FileID string `json:"file_id"`
Name string `json:"name"`
Mime string `json:"mime"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
}
// NewFileList constructs the file_list tool. storage nil → "not
// configured" at execute time.
func NewFileList(storage FileStorage) tool.Tool {
return tool.NewGatedTool[fileListArgs](
"file_list",
"List files in a scope. Returns a JSON array of {file_id, name, mime, size_bytes, created_at}. Does NOT include bytes — call file_get with a file_id to fetch content.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args fileListArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_list: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("file_list: %w", err)
}
// root_run is a KV-only scope (v1) — see file_save's guard.
if strings.HasPrefix(args.Scope, "root_run:") {
return "", fmt.Errorf("file_list: root_run scope is KV-only")
}
rows, err := storage.FileList(ctx, inv.SkillID, args.Scope)
if err != nil {
return "", fmt.Errorf("file_list: %w", err)
}
out := make([]fileListEntry, 0, len(rows))
for _, r := range rows {
out = append(out, fileListEntry{
FileID: r.ID,
Name: r.Name,
Mime: r.MimeType,
SizeBytes: r.SizeBytes,
CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339),
})
}
b, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("file_list: marshal: %w", err)
}
return string(b), nil
},
)
}
+171
View File
@@ -0,0 +1,171 @@
// file_save persists arbitrary bytes (base64-encoded by the caller)
// against a (scope, name) tuple within the calling skill's namespace.
// Returns the new file_id, the SHA256 content hash, and the size.
//
// Why base64 over raw bytes: the LLM's tool-call wire format is JSON,
// which can't carry arbitrary bytes natively. Base64 round-trips
// cleanly through the schema.
//
// Why hash + size in the response: agents commonly want to dedup
// across runs (same hash = same content) or build a manifest. Reporting
// these inline saves an immediate file_get round-trip just to compute
// them.
//
// Per-file cap: maxFileBytes (constructor arg) enforces an upper bound
// on individual file size. 0 falls back to defaultFileMaxBytes (10 MB).
//
// Per-skill quota (sum across all files): the constructor's QuotaProvider
// arg drives the v4 Phase 4 enforcement. nil disables enforcement
// (useful for tests and admin-only deployments). The check is:
//
// used := storage.FileUsageBytes(skill)
// if used + len(new content) > filesMax → quota_exceeded
//
// Note we do NOT subtract a "prior" value here the way kv_set does:
// file_save always inserts a new file row (content-addressable dedup
// is at the blob layer, not the row layer), so every save is additive
// to FileUsageBytes.
package tools
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
const defaultFileMaxBytes = 10 * 1024 * 1024 // 10 MiB
type fileSaveArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill' (shared across all callers of this skill), 'user:<your_id>' (per-caller), or 'run:<run_id>' (this run's scratchpad)."`
Name string `json:"name" description:"Filename including extension. Used for display only — the file is identified by an opaque file_id."`
ContentBase64 string `json:"content_base64" description:"Base64-encoded file content."`
Mime string `json:"mime,omitempty" description:"Optional MIME type. If omitted, detected from the first 512 bytes of content."`
}
type fileSaveResult struct {
FileID string `json:"file_id"`
Hash string `json:"hash"`
SizeBytes int64 `json:"size_bytes"`
}
// NewFileSave constructs the file_save tool.
//
// storage nil → "not configured" at execute time.
// maxFileBytes <= 0 falls back to defaultFileMaxBytes (10 MiB).
// quota nil → per-skill quota check skipped (per-file cap still applies).
//
// Permission: anyone may author; safe for share. Scope check at handler
// entry prevents cross-user writes; per-user buckets are isolated by
// inv.CallerID.
func NewFileSave(storage FileStorage, quota QuotaProvider, maxFileBytes int) tool.Tool {
if maxFileBytes <= 0 {
maxFileBytes = defaultFileMaxBytes
}
return tool.NewGatedTool[fileSaveArgs](
"file_save",
"Save base64-encoded bytes against a (scope, name) tuple. Returns file_id (opaque), SHA256 hash, and size_bytes. Content is dedup'd by hash — multiple file_save calls with identical bytes share storage. NOTE: for files produced inside code_exec, do NOT hand-encode base64 here (it corrupts) — write them to /workspace/ in the code_exec call and use the files_out file_id it returns.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "write"},
},
func(ctx context.Context, inv tool.Invocation, args fileSaveArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("file_save: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("file_save: %w", err)
}
// root_run is a KV-only scope (v1): file storage partitions
// by the calling skill, so a root_run file would silently be
// invisible to siblings AND escape the run-scope sweeper.
// Reject loudly instead.
if strings.HasPrefix(args.Scope, "root_run:") {
return "", fmt.Errorf("file_save: root_run scope is KV-only; save under run:<run_id> and share the file_id via kv_set in the root_run scope")
}
if args.Name == "" {
return "", fmt.Errorf("file_save: name required")
}
if args.ContentBase64 == "" {
return "", fmt.Errorf("file_save: content_base64 required")
}
// Decode + cap. Decoding twice (once to count, once to
// store) would waste cycles; we decode once and check size
// after.
content, err := base64.StdEncoding.DecodeString(args.ContentBase64)
if err != nil {
return "", fmt.Errorf("file_save: invalid base64: %w", err)
}
if len(content) > maxFileBytes {
return "", fmt.Errorf("file_save: file exceeds max %d bytes (got %d)", maxFileBytes, len(content))
}
// Per-skill quota gate (v4 Phase 4). Skipped when quota is nil
// (tests / admin opt-out) so the per-file cap above is the
// only line of defence in that mode.
if quota != nil {
_, filesMax, err := quota.EffectiveQuota(ctx, inv.SkillID)
if err != nil {
return "", fmt.Errorf("file_save: quota lookup: %w", err)
}
used, err := storage.FileUsageBytes(ctx, inv.SkillID)
if err != nil {
return "", fmt.Errorf("file_save: usage check: %w", err)
}
if used+int64(len(content)) > filesMax {
return "", fmt.Errorf("file_save: quota_exceeded — %d/%d bytes used; ask admin for higher quota", used, filesMax)
}
}
// SHA256 for content-addressable dedup at the storage layer.
h := sha256.Sum256(content)
hashHex := hex.EncodeToString(h[:])
mime := args.Mime
if mime == "" {
// http.DetectContentType is documented to read at most
// the first 512 bytes; passing the full slice is fine.
mime = http.DetectContentType(content)
}
meta := FileDomainMeta{
ID: uuid.NewString(),
SkillID: inv.SkillID,
Scope: args.Scope,
Name: args.Name,
ContentHash: hashHex,
MimeType: mime,
SizeBytes: int64(len(content)),
CreatedAt: time.Now(),
}
fileID, err := storage.FileSave(ctx, meta, content)
if err != nil {
return "", fmt.Errorf("file_save: %w", err)
}
res := fileSaveResult{
FileID: fileID,
Hash: hashHex,
SizeBytes: int64(len(content)),
}
b, err := json.Marshal(res)
if err != nil {
return "", fmt.Errorf("file_save: marshal result: %w", err)
}
return string(b), nil
},
)
}
+131
View File
@@ -0,0 +1,131 @@
// file_search runs a token-AND search over the per-skill (or, for
// admin authors, cross-skill) file index. Returns up to N matches with
// {file_id, name, snippet, score}.
//
// Why admin-authoring only: a public skill could otherwise probe
// other skills' file content via cross-skill search. Restricting the
// tool's authoring requirement to admins blocks shared/public skills
// from depending on file_search at all (it never appears in their
// allowed-tool catalog at save time). Within a private skill,
// admin-authored or otherwise, scope is per-call: the handler always
// pins skill_id to inv.SkillID — no matter what the LLM-supplied scope
// arg says — so a non-admin caller invoking an admin-authored public
// skill cannot escape the skill's own bucket.
//
// Why use Storage's SearchFiles directly: token logic + scoring lives
// in the skills package. The handler is a thin transcoder.
package tools
import (
"context"
"encoding/json"
"fmt"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// FileSearcher is the narrow surface the file_search tool needs.
// Production wiring (mort.go) bridges *skills.System.Storage().
// nil-safe: a nil FileSearcher surfaces "not configured" at the first
// call.
type FileSearcher interface {
SearchFiles(ctx context.Context, skillID, scope, query string, limit int) ([]FileSearchDomainHit, error)
}
// FileSearchDomainHit mirrors skills.FileSearchHit (cycle-break domain
// shape). The production adapter is a struct copy.
type FileSearchDomainHit struct {
FileID string
SkillID string
Scope string
Name string
MimeType string
Snippet string
Score int
}
type fileSearchArgs struct {
Query string `json:"query" description:"Free-text search query. Tokenised, lowercased, ANDed."`
Scope string `json:"scope,omitempty" description:"Optional storage scope to restrict the search ('skill', 'user:<your_id>', 'run:<run_id>'). Empty = all scopes within this skill."`
Limit int `json:"limit,omitempty" description:"Optional max hits to return (default 25, max 100)."`
}
type fileSearchHit struct {
FileID string `json:"file_id"`
Name string `json:"name"`
Mime string `json:"mime,omitempty"`
Snippet string `json:"snippet,omitempty"`
Score int `json:"score"`
}
// NewFileSearch constructs the file_search tool. Authoring-required
// admin so non-admins can't include this tool in shared/public skills
// (the share-safety check rejects share+admin-only as private-only).
//
// Wait — if the tool is admin-authoring AND share-safe, an admin could
// author a public skill that uses it. That's the desired flow: admin
// curates the skill, but the privacy property still holds because the
// handler PINS skill_id to inv.SkillID. A non-admin caller of the
// public skill can ONLY search files within that skill's bucket, not
// cross-skill.
//
// Setting SafeForShare=false would force this tool to be private-only;
// that's needlessly restrictive. The privacy property comes from the
// per-call skill_id pin, not from share-time gating.
func NewFileSearch(searcher FileSearcher) tool.Tool {
return tool.NewGatedTool[fileSearchArgs](
"file_search",
"Full-text search over this skill's saved files. Returns array of {file_id, name, snippet, score} ordered by score desc. Tokens are lowercased + ANDed. Admin-authored only — non-admin callers of an admin-authored public skill still see only that skill's files.",
tool.Permission{
AuthoringRequirement: tool.RequirementAdmin,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args fileSearchArgs) (string, error) {
if searcher == nil {
return "", fmt.Errorf("file_search: not configured")
}
if args.Query == "" {
return "", fmt.Errorf("file_search: query required")
}
limit := args.Limit
if limit <= 0 {
limit = 25
}
if limit > 100 {
limit = 100
}
scope := args.Scope
if scope != "" {
if err := ValidateScope(inv, scope, false); err != nil {
return "", fmt.Errorf("file_search: %w", err)
}
}
// Pin skill_id to the invoking skill — even if the LLM
// supplies a different value somewhere, the handler always
// scopes to inv.SkillID. This is the privacy guarantee
// referenced in the package doc.
rows, err := searcher.SearchFiles(ctx, inv.SkillID, scope, args.Query, limit)
if err != nil {
return "", fmt.Errorf("file_search: %w", err)
}
out := make([]fileSearchHit, 0, len(rows))
for _, r := range rows {
out = append(out, fileSearchHit{
FileID: r.FileID,
Name: r.Name,
Mime: r.MimeType,
Snippet: r.Snippet,
Score: r.Score,
})
}
b, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("file_search: marshal: %w", err)
}
return string(b), nil
},
)
}
+52
View File
@@ -0,0 +1,52 @@
// kv_delete removes a single entry by (scope, key). Missing rows
// surface as the literal string "not_found" rather than an error so the
// LLM can reason "did this row exist?" without wrapping the call in
// error handling.
package tools
import (
"context"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type kvDeleteArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>'."`
Key string `json:"key" description:"Key within the scope."`
}
// NewKVDelete constructs the kv_delete tool. storage nil → "not
// configured" at execute time.
func NewKVDelete(storage KVStorage) tool.Tool {
return tool.NewGatedTool[kvDeleteArgs](
"kv_delete",
"Remove an entry by (scope, key). Returns 'ok' on success or 'not_found' if no row matched.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "write"},
},
func(ctx context.Context, inv tool.Invocation, args kvDeleteArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("kv_delete: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("kv_delete: %w", err)
}
if args.Key == "" {
return "", fmt.Errorf("kv_delete: key required")
}
if err := storage.KVDelete(ctx, kvPartition(inv, args.Scope), args.Scope, args.Key); err != nil {
if errors.Is(err, ErrKVNotFound) {
return "not_found", nil
}
return "", fmt.Errorf("kv_delete: %w", err)
}
return "ok", nil
},
)
}
+63
View File
@@ -0,0 +1,63 @@
// kv_get is the v4 KV-storage read tool. It looks up a single value by
// (scope, key) within the calling skill's KV namespace and returns the
// stored JSON value, or `null` when no row matches.
//
// Why "null" on miss (vs an error): the LLM's most natural use is
// "fetch this if cached, otherwise compute and store". Miss-as-error
// would force the agent to wrap every call in error handling; miss-as-
// null collapses the happy path.
package tools
import (
"context"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
type kvGetArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill' (shared across all callers of this skill), 'user:<your_id>' (per-caller), 'run:<run_id>' (this run's scratchpad), or 'root_run:<root_run_id>' (shared scratchpad of this whole dispatch tree — use to coordinate with parallel sibling workers)."`
Key string `json:"key" description:"Key within the scope."`
}
// NewKVGet constructs the kv_get tool. storage may be nil — the tool
// then surfaces "not configured" at execute time instead of failing
// registration.
//
// Permission: anyone may author; safe for share. The scope check at
// handler entry makes share-safety meaningful — a shared skill cannot
// read another caller's `user:<id>` bucket because ValidateScope
// rejects that.
func NewKVGet(storage KVStorage) tool.Tool {
return tool.NewGatedTool[kvGetArgs](
"kv_get",
"Look up a value by key in this skill's storage. Returns the stored JSON value, or `null` if no row matches the (scope, key) tuple.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args kvGetArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("kv_get: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("kv_get: %w", err)
}
if args.Key == "" {
return "", fmt.Errorf("kv_get: key required")
}
entry, err := storage.KVGet(ctx, kvPartition(inv, args.Scope), args.Scope, args.Key)
if err != nil {
if errors.Is(err, ErrKVNotFound) {
return "null", nil
}
return "", fmt.Errorf("kv_get: %w", err)
}
return string(entry.Value), nil
},
)
}
+88
View File
@@ -0,0 +1,88 @@
// kv_list returns metadata (key, size, expiry) for entries within a
// scope, optionally filtered by key prefix. Values are NOT loaded —
// listing is a hot path that should stay light, and dumping every
// value byte into the LLM context would burn tokens for no benefit.
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
const (
kvListDefaultLimit = 100
kvListMaxLimit = 1000
)
type kvListArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>'."`
Prefix string `json:"prefix,omitempty" description:"Optional key-prefix filter. Empty matches all keys in the scope."`
Limit int `json:"limit,omitempty" description:"Max entries to return. Default 100, hard cap 1000."`
}
type kvListEntry struct {
Key string `json:"key"`
SizeBytes int `json:"size_bytes"`
// ExpiresAt is RFC3339 when set, "" otherwise. JSON serialised this
// way so the LLM can reason about it as a string field consistently
// (rather than null vs. missing key).
ExpiresAt string `json:"expires_at,omitempty"`
}
// NewKVList constructs the kv_list tool. storage nil → "not configured"
// at execute time.
func NewKVList(storage KVStorage) tool.Tool {
return tool.NewGatedTool[kvListArgs](
"kv_list",
"List keys + sizes + expiries in a scope (optionally filtered by key prefix). Returns a JSON array. Does NOT include values — call kv_get to fetch a specific value.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "read"},
},
func(ctx context.Context, inv tool.Invocation, args kvListArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("kv_list: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("kv_list: %w", err)
}
limit := args.Limit
if limit <= 0 {
limit = kvListDefaultLimit
}
if limit > kvListMaxLimit {
limit = kvListMaxLimit
}
rows, err := storage.KVList(ctx, kvPartition(inv, args.Scope), args.Scope, args.Prefix, limit)
if err != nil {
return "", fmt.Errorf("kv_list: %w", err)
}
out := make([]kvListEntry, 0, len(rows))
for _, r := range rows {
e := kvListEntry{
Key: r.Key,
SizeBytes: len(r.Value),
}
if r.ExpiresAt != nil {
e.ExpiresAt = r.ExpiresAt.Format(time.RFC3339)
}
out = append(out, e)
}
b, err := json.Marshal(out)
if err != nil {
return "", fmt.Errorf("kv_list: marshal: %w", err)
}
return string(b), nil
},
)
}
+145
View File
@@ -0,0 +1,145 @@
// kv_set is the v4 KV-storage write tool. It upserts (scope, key) →
// value within the calling skill's namespace, with optional TTL.
//
// Per-value cap: the constructor takes maxValueBytes (typically read
// from convar `skills.storage.kv_max_value_bytes`); 0 means use the
// 64 KiB default.
//
// Per-skill quota (sum across all rows): the constructor's QuotaProvider
// arg drives the v4 Phase 4 enforcement. nil disables enforcement
// (useful for tests and admin-only deployments). The check is:
//
// used := storage.KVUsageBytes(skill)
// delta := len(new value) - len(prior value if updating same key)
// if used + delta > kvMax → quota_exceeded
//
// We subtract the existing value's size on UPDATE so an in-place edit
// of a hot key never trips the cap unless the new value is larger.
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
const defaultKVMaxValueBytes = 65536 // 64 KiB
type kvSetArgs struct {
Scope string `json:"scope" description:"Storage scope: 'skill', 'user:<your_id>', 'run:<run_id>', or 'root_run:<root_run_id>' (shared across the whole dispatch tree)."`
Key string `json:"key" description:"Key within the scope."`
Value json.RawMessage `json:"value" description:"JSON value to store. Must parse as valid JSON (object, array, string, number, bool, or null)."`
TTLSeconds *int `json:"ttl_seconds,omitempty" description:"Optional TTL in seconds. The entry expires (and is lazy-purged on read) after this duration."`
}
// NewKVSet constructs the kv_set tool.
//
// storage nil → "not configured" at execute time.
// maxValueBytes <= 0 falls back to defaultKVMaxValueBytes.
// quota nil → per-skill quota check is skipped (per-value cap still
// applies).
func NewKVSet(storage KVStorage, quota QuotaProvider, maxValueBytes int) tool.Tool {
if maxValueBytes <= 0 {
maxValueBytes = defaultKVMaxValueBytes
}
return tool.NewGatedTool[kvSetArgs](
"kv_set",
"Set a value at the given scope+key. Optionally with a TTL after which the entry auto-expires.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"storage", "write"},
},
func(ctx context.Context, inv tool.Invocation, args kvSetArgs) (string, error) {
if storage == nil {
return "", fmt.Errorf("kv_set: not configured")
}
if err := ValidateScope(inv, args.Scope, false); err != nil {
return "", fmt.Errorf("kv_set: %w", err)
}
if args.Key == "" {
return "", fmt.Errorf("kv_set: key required")
}
if len(args.Value) == 0 {
return "", fmt.Errorf("kv_set: value required")
}
if len(args.Value) > maxValueBytes {
return "", fmt.Errorf("kv_set: value exceeds max %d bytes (got %d)", maxValueBytes, len(args.Value))
}
// Validate JSON. The storage layer treats the raw bytes as
// opaque, but the LLM contract says "value is a JSON value"
// — surfacing a parse error here gives a friendlier message
// than letting an invalid blob round-trip and confuse the
// reader on a future kv_get.
var probe any
if err := json.Unmarshal(args.Value, &probe); err != nil {
return "", fmt.Errorf("kv_set: value is not valid JSON: %w", err)
}
partition := kvPartition(inv, args.Scope)
// Per-skill quota gate (v4 Phase 4). Skipped when quota is nil
// (tests / admin opt-out) so the per-value cap above is the
// only line of defence in that mode. Also skipped for the
// shared root_run partition — per-skill quota attribution is
// meaningless across the sentinel; the per-value cap above +
// the run-scope sweeper bound that partition's growth.
if quota != nil && partition == inv.SkillID {
kvMax, _, err := quota.EffectiveQuota(ctx, inv.SkillID)
if err != nil {
return "", fmt.Errorf("kv_set: quota lookup: %w", err)
}
used, err := storage.KVUsageBytes(ctx, inv.SkillID)
if err != nil {
return "", fmt.Errorf("kv_set: usage check: %w", err)
}
delta := int64(len(args.Value))
// On UPDATE, subtract the prior value's size so an
// in-place edit of a hot key doesn't double-count. A
// brand-new key (KVGet returns ErrKVNotFound) leaves
// delta untouched.
if existing, getErr := storage.KVGet(ctx, inv.SkillID, args.Scope, args.Key); getErr == nil && existing != nil {
delta -= int64(len(existing.Value))
} else if getErr != nil && !errors.Is(getErr, ErrKVNotFound) {
return "", fmt.Errorf("kv_set: pre-write lookup: %w", getErr)
}
if used+delta > kvMax {
return "", fmt.Errorf("kv_set: quota_exceeded — %d/%d bytes used; ask admin for higher quota", used, kvMax)
}
}
now := time.Now()
entry := KVDomainEntry{
SkillID: partition,
Scope: args.Scope,
Key: args.Key,
Value: args.Value,
CreatedAt: now,
UpdatedAt: now,
}
if args.TTLSeconds != nil && *args.TTLSeconds > 0 {
expires := now.Add(time.Duration(*args.TTLSeconds) * time.Second)
entry.ExpiresAt = &expires
}
if err := storage.KVSet(ctx, entry); err != nil {
return "", fmt.Errorf("kv_set: %w", err)
}
// V7 versioned KV history (admin diagnostic). Best-effort —
// a failed history write must NOT shadow the successful
// kv_set return, so we ignore the error after logging.
// Production adapter satisfies KVHistoryRecorder; tests
// using a bare KVStorage skip this branch entirely.
if h, ok := storage.(KVHistoryRecorder); ok && h != nil {
_ = h.RecordKVHistory(ctx, partition, args.Scope, args.Key, []byte(args.Value), inv.CallerID)
}
return "ok", nil
},
)
}
+89
View File
@@ -0,0 +1,89 @@
// 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")
+32
View File
@@ -0,0 +1,32 @@
// quota_provider.go declares the narrow QuotaProvider interface used by
// kv_set and file_save to enforce per-skill byte quotas at write time.
//
// Why a narrow interface (vs importing pkg/logic/skills directly): same
// cycle constraint as kv_storage.go and file_storage.go — pkg/logic/skills
// already imports pkg/skilltools, so importing skills back here would
// form an import cycle. Production wiring (pkg/logic/mort.go) supplies
// *skills.System, which satisfies QuotaProvider via its EffectiveQuota
// method.
//
// Why a separate interface vs adding the method to KVStorage/FileStorage:
// quota resolution is a system-level policy (combining override + convar
// + default), not a pure storage read. Keeping it separate lets a tool
// constructor accept a nil QuotaProvider when an integrator wants to
// skip enforcement (e.g. an admin-only skill that bypasses caps).
package tools
import "context"
// QuotaProvider returns effective per-skill quotas for the storage
// tools' write-path enforcement. Production wires *skills.System, which
// satisfies this via its EffectiveQuota method.
//
// nil-safe: tools constructed against a nil QuotaProvider do NOT enforce
// per-skill quotas. That mode is useful for tests and for environments
// where quota enforcement is intentionally disabled.
type QuotaProvider interface {
// EffectiveQuota returns the effective KV and file byte caps for the
// skill. The two values resolve admin overrides + convar defaults +
// package constants in that order.
EffectiveQuota(ctx context.Context, skillID string) (kvMax, filesMax int64, err error)
}
+77
View File
@@ -0,0 +1,77 @@
package tools
import (
"context"
"errors"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// StoreDeps wires the persistent-memory tools (kv_* and file_*). A host
// supplies its KV and/or File backends; the kv group registers only when KV is
// set and the file group only when Files is set, so a host can take just one.
// Everything else has a sensible default:
//
// - Quota defaults to a generous static cap (a host that meters per-skill
// storage supplies its own QuotaProvider).
// - FileSearch / Minter+BaseURL are optional — file_search and
// create_file_url register only when wired.
// - MaxValueBytes / MaxFileBytes default when non-positive.
type StoreDeps struct {
KV KVStorage
Files FileStorage
Quota QuotaProvider
FileSearch FileSearcher
Minter FileTokenMinter
BaseURL string
MaxValueBytes int // kv_set per-value cap; default 256 KiB
MaxFileBytes int // file_save per-file cap; default 16 MiB
}
// RegisterStore registers the kv_* tools (when KV is set) and the file_* tools
// (when Files is set). At least one of KV/Files is required.
func RegisterStore(reg tool.Registry, d StoreDeps) error {
if d.KV == nil && d.Files == nil {
return errors.New("tools: RegisterStore needs at least KV or Files")
}
if d.Quota == nil {
d.Quota = staticQuota{kvMax: 64 << 20, filesMax: 1 << 30}
}
if d.MaxValueBytes <= 0 {
d.MaxValueBytes = 256 << 10
}
if d.MaxFileBytes <= 0 {
d.MaxFileBytes = 16 << 20
}
var ts []tool.Tool
if d.KV != nil {
ts = append(ts,
NewKVGet(d.KV), NewKVSet(d.KV, d.Quota, d.MaxValueBytes),
NewKVList(d.KV), NewKVDelete(d.KV),
)
}
if d.Files != nil {
ts = append(ts,
NewFileSave(d.Files, d.Quota, d.MaxFileBytes),
NewFileGet(d.Files), NewFileGetText(d.Files), NewFileGetMetadata(d.Files),
NewFileList(d.Files), NewFileDelete(d.Files),
)
if d.FileSearch != nil {
ts = append(ts, NewFileSearch(d.FileSearch))
}
if d.Minter != nil && d.BaseURL != "" {
ts = append(ts, NewCreateFileURL(d.Minter, d.Files, d.BaseURL))
}
}
return registerAll(reg, ts...)
}
// staticQuota is the default QuotaProvider: a fixed KV/file byte cap for every
// skill. A host that needs per-skill metering supplies its own.
type staticQuota struct{ kvMax, filesMax int64 }
func (q staticQuota) EffectiveQuota(context.Context, string) (kvMax, filesMax int64, err error) {
return q.kvMax, q.filesMax, nil
}