P3: generic tool library (think/now/cite + meta + store groups) #3
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user