From ac961e15393334aafe654d89c4fb1d2c23048f3c Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Fri, 26 Jun 2026 22:06:46 -0400 Subject: [PATCH] =?UTF-8?q?P3:=20store=20group=20=E2=80=94=20kv=5F*=20+=20?= =?UTF-8?q?file=5F*=20tools=20(agent=20memory)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 11 +- tools/create_file_url.go | 196 +++++++++++++++++++++++++++++++++ tools/file_delete.go | 70 ++++++++++++ tools/file_descendant_grant.go | 58 ++++++++++ tools/file_get.go | 103 +++++++++++++++++ tools/file_get_metadata.go | 91 +++++++++++++++ tools/file_get_text.go | 119 ++++++++++++++++++++ tools/file_list.go | 74 +++++++++++++ tools/file_save.go | 171 ++++++++++++++++++++++++++++ tools/file_search.go | 131 ++++++++++++++++++++++ tools/kv_delete.go | 52 +++++++++ tools/kv_get.go | 63 +++++++++++ tools/kv_list.go | 88 +++++++++++++++ tools/kv_set.go | 145 ++++++++++++++++++++++++ tools/kv_storage.go | 89 +++++++++++++++ tools/quota_provider.go | 32 ++++++ tools/store.go | 77 +++++++++++++ 17 files changed, 1565 insertions(+), 5 deletions(-) create mode 100644 tools/create_file_url.go create mode 100644 tools/file_delete.go create mode 100644 tools/file_descendant_grant.go create mode 100644 tools/file_get.go create mode 100644 tools/file_get_metadata.go create mode 100644 tools/file_get_text.go create mode 100644 tools/file_list.go create mode 100644 tools/file_save.go create mode 100644 tools/file_search.go create mode 100644 tools/kv_delete.go create mode 100644 tools/kv_get.go create mode 100644 tools/kv_list.go create mode 100644 tools/kv_set.go create mode 100644 tools/kv_storage.go create mode 100644 tools/quota_provider.go create mode 100644 tools/store.go diff --git a/CLAUDE.md b/CLAUDE.md index 03e3439..0c92127 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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] diff --git a/tools/create_file_url.go b/tools/create_file_url.go new file mode 100644 index 0000000..bf1b1db --- /dev/null +++ b/tools/create_file_url.go @@ -0,0 +1,196 @@ +// create_file_url mints a public-token URL (mort.sh/files/) +// 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/ 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/" +// 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/) 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 +} diff --git a/tools/file_delete.go b/tools/file_delete.go new file mode 100644 index 0000000..3c560bc --- /dev/null +++ b/tools/file_delete.go @@ -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 + }, + ) +} diff --git a/tools/file_descendant_grant.go b/tools/file_descendant_grant.go new file mode 100644 index 0000000..6c98203 --- /dev/null +++ b/tools/file_descendant_grant.go @@ -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 +} diff --git a/tools/file_get.go b/tools/file_get.go new file mode 100644 index 0000000..dba16e2 --- /dev/null +++ b/tools/file_get.go @@ -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 + }, + ) +} diff --git a/tools/file_get_metadata.go b/tools/file_get_metadata.go new file mode 100644 index 0000000..a45cbbe --- /dev/null +++ b/tools/file_get_metadata.go @@ -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 + }, + ) +} diff --git a/tools/file_get_text.go b/tools/file_get_text.go new file mode 100644 index 0000000..00a0a1d --- /dev/null +++ b/tools/file_get_text.go @@ -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 +} diff --git a/tools/file_list.go b/tools/file_list.go new file mode 100644 index 0000000..7cbacc0 --- /dev/null +++ b/tools/file_list.go @@ -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:', or 'run:'."` +} + +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 + }, + ) +} diff --git a/tools/file_save.go b/tools/file_save.go new file mode 100644 index 0000000..79c7663 --- /dev/null +++ b/tools/file_save.go @@ -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:' (per-caller), or 'run:' (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: 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 + }, + ) +} diff --git a/tools/file_search.go b/tools/file_search.go new file mode 100644 index 0000000..4db75c9 --- /dev/null +++ b/tools/file_search.go @@ -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:', 'run:'). 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 + }, + ) +} diff --git a/tools/kv_delete.go b/tools/kv_delete.go new file mode 100644 index 0000000..b00a429 --- /dev/null +++ b/tools/kv_delete.go @@ -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:', 'run:', or 'root_run:'."` + 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 + }, + ) +} diff --git a/tools/kv_get.go b/tools/kv_get.go new file mode 100644 index 0000000..59b303d --- /dev/null +++ b/tools/kv_get.go @@ -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:' (per-caller), 'run:' (this run's scratchpad), or 'root_run:' (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:` 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 + }, + ) +} diff --git a/tools/kv_list.go b/tools/kv_list.go new file mode 100644 index 0000000..6436b6c --- /dev/null +++ b/tools/kv_list.go @@ -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:', 'run:', or 'root_run:'."` + 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 + }, + ) +} diff --git a/tools/kv_set.go b/tools/kv_set.go new file mode 100644 index 0000000..f9cfc17 --- /dev/null +++ b/tools/kv_set.go @@ -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:', 'run:', or 'root_run:' (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 + }, + ) +} diff --git a/tools/kv_storage.go b/tools/kv_storage.go new file mode 100644 index 0000000..3166d9b --- /dev/null +++ b/tools/kv_storage.go @@ -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:` +// 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") diff --git a/tools/quota_provider.go b/tools/quota_provider.go new file mode 100644 index 0000000..72a3879 --- /dev/null +++ b/tools/quota_provider.go @@ -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) +} diff --git a/tools/store.go b/tools/store.go new file mode 100644 index 0000000..0ead05b --- /dev/null +++ b/tools/store.go @@ -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 +}