P3: meta + primitive tool group (think/now/cite + classify/extract/summarize)

Grow executus/tools into a real generic tool library:

- Register(reg): the always-available, zero-config tools — think, now (UTC
  unless a CurrentTimeProvider is wired), cite (inert unless a CitationStorage
  is wired). All nil-safe; a light host calls Register and is useful.
- RegisterMeta(reg, MetaDeps): the LLM-backed meta tools — classify,
  extract_entities, summarize — over the llmmeta helper. Budget defaults to the
  shipped in-memory per-run cap; Files optional; caps default.
- Seams moved (interface/type-only, no host coupling): research_providers.go
  (CurrentTimeProvider/CitationStorage/SearchBudget/PageExtractor/PDFFetcher/…)
  and file_storage.go (FileStorage + FileDomainMeta). Plus the in-memory budget
  default (research_defaults.go) and scope_validate.go.

calculate deferred (drags github.com/Krognol/go-wolfram + a module-path replace
— not worth it in the lean core for one tool). Core go.sum still free of
gorm/redis/discordgo/sqlite/wolfram.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 21:00:45 -04:00
parent df95425bb5
commit 1e201550b3
11 changed files with 1802 additions and 17 deletions
+7 -5
View File
@@ -58,11 +58,13 @@ CORE (majordomo + stdlib):
structured output — no separate structured/ pkg) structured output — no separate structured/ pkg)
llmmeta/ shared meta-LLM helper over model/ [P1 ✓] llmmeta/ shared meta-LLM helper over model/ [P1 ✓]
compact/ context compactor (WithCompactor hook) [P2 ✓] compact/ context compactor (WithCompactor hook) [P2 ✓]
tools/ generic tool library + Register entrypoint; [P3 wip] tools/ generic tool library: Register (think/now/ [P3 wip]
think moved; end-to-end "agent calls a tool" cite, zero-config) + RegisterMeta (classify/
test green. Remaining: meta/web/net/store/ extract_entities/summarize); seams in
compose groups + their nil-safe Deps + default research_providers.go/file_storage.go;
backends (the default.go grab-bag split) [P3] in-memory budget default. End-to-end "agent
calls a tool" test green. Remaining: web/net/
store/compose groups + their backends [P3]
BATTERIES (opt-in siblings, each nil-safe + a default): BATTERIES (opt-in siblings, each nil-safe + a default):
persona/ Agent noun + AgentStore seam + yml loader [P4] persona/ Agent noun + AgentStore seam + yml loader [P4]
+128
View File
@@ -0,0 +1,128 @@
// Package tools — v11 cite.
//
// Anti-hallucination forcing function. The convention: agents call
// cite(claim, url) for every numbered reference in their final
// answer. The tool verifies the URL appears in the run's
// touched-URL set (populated by web_search results +
// read_page/read_pdf/read_video). If yes → write to
// skill_run_sources, return {ok: true}. If no → return
// {ok: false, reason: "url_not_in_run_history"} and DO NOT write.
//
// Skills authored without this discipline don't lose anything;
// skills WITH it produce more reliable citations. The webui
// renders the skill_run_sources rows as a Sources panel on the
// run trace page — invisible to skills that don't use cite().
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// citeParams is the LLM-facing param struct.
type citeParams struct {
Claim string `json:"claim" description:"The claim or fact you are asserting (e.g. 'Mort was published in 1987')."`
URL string `json:"url" description:"The URL that supports the claim. MUST be a URL the agent has previously read via read_page/read_pdf/read_video or seen as a web_search result."`
}
// citeResponse is the JSON envelope returned to the agent.
//
// On success: ok=true, the skill_run_sources row was written.
// On failure: ok=false, reason=<one of the documented sentinels>.
type citeResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
Claim string `json:"claim,omitempty"`
URL string `json:"url,omitempty"`
}
// NewCite constructs the v11 cite tool. cs may be nil — handler
// returns "not configured" at first call.
//
// The "anyone author / share-safe" permission shape matches every
// other v11 research-class tool. Skills that adopt cite() get the
// Sources panel automatically; skills that don't are unaffected.
func NewCite(cs CitationStorage) tool.Tool {
return tool.NewGatedTool[citeParams](
"cite",
"Record a citation: a claim + the URL that supports it. The URL MUST be one the agent has actually fetched via read_page/read_pdf/read_video or seen as a web_search result — citing a URL the agent never visited is rejected with reason 'url_not_in_run_history'. Successful citations populate the run's Sources panel.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeGlobal,
SafeForShare: true,
Categories: []string{"citation"},
},
func(ctx context.Context, inv tool.Invocation, p citeParams) (string, error) {
if cs == nil {
return "", fmt.Errorf("cite: citation storage not configured")
}
claim := strings.TrimSpace(p.Claim)
urlStr := strings.TrimSpace(p.URL)
if claim == "" {
return marshalCite(citeResponse{
OK: false,
Reason: "claim_empty",
}), nil
}
if urlStr == "" {
return marshalCite(citeResponse{
OK: false,
Reason: "url_empty",
}), nil
}
if inv.RunID == "" {
// No run id → cite() can't verify history. Bail loud.
return marshalCite(citeResponse{
OK: false,
Reason: "no_run_context",
Claim: claim,
URL: urlStr,
}), nil
}
touched, err := cs.GetTouchedURLs(ctx, inv.RunID)
if err != nil {
return marshalCite(citeResponse{
OK: false,
Reason: fmt.Sprintf("touched_lookup_failed: %v", err),
Claim: claim,
URL: urlStr,
}), nil
}
if _, ok := touched[urlStr]; !ok {
return marshalCite(citeResponse{
OK: false,
Reason: "url_not_in_run_history",
Claim: claim,
URL: urlStr,
}), nil
}
if err := cs.RecordCitation(ctx, inv.RunID, urlStr, claim); err != nil {
return marshalCite(citeResponse{
OK: false,
Reason: fmt.Sprintf("record_failed: %v", err),
Claim: claim,
URL: urlStr,
}), nil
}
return marshalCite(citeResponse{
OK: true,
Claim: claim,
URL: urlStr,
}), nil
},
)
}
func marshalCite(r citeResponse) string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf(`{"ok":false,"reason":"marshal_failed: %v"}`, err)
}
return string(b)
}
+319
View File
@@ -0,0 +1,319 @@
// Package tools — v12 classify.
//
// Classification primitive: text + categories → labels + per-category
// scores. Single-label mode (default) returns the top-1 category;
// multi-label mode returns every category whose score crosses the
// threshold.
//
// Why a dedicated tool (vs reusing extract_entities for one-of-N
// classification): classification has a typed result (labels[] +
// scores{}) that downstream agents consume programmatically. Folding
// it into extract_entities would force every author to re-spec the
// scoring schema.
//
// Score normalisation: the LLM's reply is normalised so each score
// lands in [0, 1]. The single-label result returns scores for ALL
// categories so the author can read the distribution; multi-label
// returns labels[] of categories above 0.5.
//
// Test: classify_test.go covers single-label, multi-label, score
// normalisation, > 20 categories rejected, unknown category in the
// reply silently dropped.
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// classifyMaxInputBytes is the input cap.
const classifyMaxInputBytes = 16 * 1024
// classifyMaxCategories is the hard cap on category count.
const classifyMaxCategories = 20
// classifyMultiLabelThreshold is the score threshold above which a
// category appears in the labels[] array in multi-label mode.
const classifyMultiLabelThreshold = 0.5
// classifyFallbackMaxPerRun is the per-run cap when ClassifyConfig is
// nil.
const classifyFallbackMaxPerRun = 20
// ClassifyConfig is the narrow per-deployment config surface.
type ClassifyConfig interface {
MaxPerRun(ctx context.Context) int
}
// classifyArgs is the LLM-facing param struct.
type classifyArgs struct {
Text string `json:"text" description:"The text to classify. Required. Capped at 16KB."`
Categories []string `json:"categories" description:"List of categories to score the text against. Required. Max 20."`
MultiLabel bool `json:"multi_label,omitempty" description:"When true, returns every category scoring above 0.5. Default false → single-label (top-1) result."`
}
type classifyResult struct {
Labels []string `json:"labels,omitempty"`
Scores map[string]float64 `json:"scores,omitempty"`
ModelUsed string `json:"model_used,omitempty"`
RawReply string `json:"raw_reply,omitempty"`
Error string `json:"error,omitempty"`
BudgetMsg string `json:"budget_message,omitempty"`
}
// NewClassify constructs the classify tool.
func NewClassify(helper *llmmeta.Helper, cfg ClassifyConfig, budget SearchBudget) tool.Tool {
return tool.NewGatedTool[classifyArgs](
"classify",
"Classify text into one of N categories (or multiple via multi_label=true). Returns labels[] (top-1 by default) + scores{category: 0..1}. Counts against per-run and 7-day cost budgets.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"llm-meta", "cost-bearing"},
},
func(ctx context.Context, inv tool.Invocation, args classifyArgs) (string, error) {
if helper == nil {
return "", fmt.Errorf("classify: not configured")
}
text := args.Text
if strings.TrimSpace(text) == "" {
return marshalClassifyResult(classifyResult{Error: "text is empty"}), nil
}
if len(args.Categories) == 0 {
return marshalClassifyResult(classifyResult{Error: "categories is empty"}), nil
}
if len(args.Categories) > classifyMaxCategories {
return marshalClassifyResult(classifyResult{
Error: fmt.Sprintf("too many categories (%d > %d)", len(args.Categories), classifyMaxCategories),
}), nil
}
// Trim + dedupe categories so the LLM sees a clean
// schema. Order is preserved for the prompt; the result
// map is order-agnostic.
categories := make([]string, 0, len(args.Categories))
seen := make(map[string]bool, len(args.Categories))
for _, c := range args.Categories {
c = strings.TrimSpace(c)
if c == "" || seen[c] {
continue
}
seen[c] = true
categories = append(categories, c)
}
if len(categories) == 0 {
return marshalClassifyResult(classifyResult{Error: "categories has no non-empty entries"}), nil
}
if len(text) > classifyMaxInputBytes {
text = text[:classifyMaxInputBytes]
}
// Per-run budget gate.
if budget == nil {
maxPerRun := classifyFallbackMaxPerRun
if cfg != nil {
maxPerRun = cfg.MaxPerRun(ctx)
}
budget = NewInMemorySearchBudget(map[string]int{
"classify": maxPerRun,
})
}
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "classify")
if exceeded {
return marshalClassifyResult(classifyResult{
Error: "classify_budget_exceeded",
BudgetMsg: fmt.Sprintf("per-run classify budget exceeded (%d/%d). Ask an admin to raise skills.classify.max_per_run.", count, max),
}), nil
}
systemPrompt := "You classify text into a fixed set of categories. Return ONLY JSON. Score each category in [0,1] (1 = perfect fit). Sum of all scores does NOT need to be 1 — high overlap across categories is allowed."
userPrompt := buildClassifyPrompt(text, categories, args.MultiLabel)
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
Tier: "fast",
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
MaxOutputTokens: 2048,
ResponseFormat: "json",
RetryOnMalformedJSON: true,
ToolName: "classify",
RunID: inv.RunID,
SkillID: inv.SkillID,
CallerID: inv.CallerID,
})
if callErr != nil {
return "", callErr
}
if !res.Success {
kind := res.ErrorKind
if kind == "" {
kind = "llm_unavailable"
}
return marshalClassifyResult(classifyResult{Error: kind}), nil
}
if res.ErrorKind == llmmeta.ErrorKindMalformedJSON || res.Parsed == nil {
return marshalClassifyResult(classifyResult{
Error: "classification_failed",
RawReply: res.Text,
ModelUsed: res.ModelUsed,
}), nil
}
parsedMap, ok := res.Parsed.(map[string]any)
if !ok {
return marshalClassifyResult(classifyResult{
Error: "classification_failed_not_object",
RawReply: res.Text,
ModelUsed: res.ModelUsed,
}), nil
}
scores := normaliseClassifyScores(parsedMap, categories)
labels := selectClassifyLabels(scores, categories, args.MultiLabel)
return marshalClassifyResult(classifyResult{
Labels: labels,
Scores: scores,
ModelUsed: res.ModelUsed,
}), nil
},
)
}
// buildClassifyPrompt composes the user message.
func buildClassifyPrompt(text string, categories []string, multiLabel bool) string {
var sb strings.Builder
sb.WriteString("Classify the text below.\n\nCategories:\n")
for _, c := range categories {
sb.WriteString("- ")
sb.WriteString(c)
sb.WriteString("\n")
}
sb.WriteString("\nText:\n")
sb.WriteString(text)
sb.WriteString("\n\nReturn ONLY a JSON object: {\"scores\": {\"<category>\": <0..1 float>, ...}}.")
if multiLabel {
sb.WriteString(" The same text may score high in MULTIPLE categories — score each independently.")
} else {
sb.WriteString(" Score each category; the highest-scoring one will be the chosen label.")
}
return sb.String()
}
// normaliseClassifyScores extracts the scores map from the LLM's
// reply and clamps each value into [0, 1]. Categories absent from the
// reply default to 0.
//
// Why we accept either {"scores": {...}} or {...}: some models reply
// with the inner object directly, dropping the wrapping key. Both
// shapes are valid as long as the keys match the requested category
// names.
func normaliseClassifyScores(parsed map[string]any, categories []string) map[string]float64 {
scoresIn, ok := parsed["scores"].(map[string]any)
if !ok {
// Accept the bare-map shape too.
scoresIn = parsed
}
out := make(map[string]float64, len(categories))
for _, c := range categories {
v, has := scoresIn[c]
if !has {
out[c] = 0
continue
}
f, ok := coerceClassifyScore(v)
if !ok {
out[c] = 0
continue
}
// Clamp into [0, 1].
if f < 0 {
f = 0
}
if f > 1 {
f = 1
}
out[c] = f
}
return out
}
// coerceClassifyScore reads a JSON value as a float in [0, 1]. Accepts
// floats, ints, and percent-strings ("85%" → 0.85).
func coerceClassifyScore(raw any) (float64, bool) {
switch v := raw.(type) {
case float64:
return v, true
case int:
return float64(v), true
case int64:
return float64(v), true
case string:
s := strings.TrimSuffix(strings.TrimSpace(v), "%")
var f float64
if _, err := fmt.Sscanf(s, "%f", &f); err == nil {
if strings.HasSuffix(strings.TrimSpace(v), "%") {
f = f / 100.0
}
return f, true
}
}
return 0, false
}
// selectClassifyLabels picks the labels to surface. Single-label mode
// returns the highest-scoring category. Multi-label returns every
// category above the threshold (sorted by score desc for stable
// rendering).
func selectClassifyLabels(scores map[string]float64, categories []string, multiLabel bool) []string {
if multiLabel {
var labels []string
for _, c := range categories {
if scores[c] >= classifyMultiLabelThreshold {
labels = append(labels, c)
}
}
// Sort labels by score desc, then category-list order for ties.
sortClassifyLabelsByScore(labels, scores)
return labels
}
// Single-label: top-1.
bestCat := ""
bestScore := -1.0
for _, c := range categories {
if scores[c] > bestScore {
bestScore = scores[c]
bestCat = c
}
}
if bestCat == "" {
return nil
}
return []string{bestCat}
}
// sortClassifyLabelsByScore sorts labels desc by score. Stable on
// ties (preserves category-list order).
func sortClassifyLabelsByScore(labels []string, scores map[string]float64) {
for i := 1; i < len(labels); i++ {
j := i
for j > 0 && scores[labels[j]] > scores[labels[j-1]] {
labels[j], labels[j-1] = labels[j-1], labels[j]
j--
}
}
}
func marshalClassifyResult(r classifyResult) string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
}
return string(b)
}
+342
View File
@@ -0,0 +1,342 @@
// Package tools — v12 extract_entities.
//
// Structured-output workhorse: text + field schema → typed JSON
// object. The author specifies which fields they want and what
// types; the tool builds an appropriate prompt, asks for JSON, and
// validates + coerces the response back into the requested types.
//
// Why a structured-output tool (vs forcing the agent to write its
// own prompt): every agentic skill that needs to "pull X, Y, Z out
// of unstructured text" otherwise re-invents the same prompt-
// engineering pattern. extract_entities centralises it so authors
// just describe the schema.
//
// Type coercion: an LLM responding with "42" when an int field was
// requested is normal noise. The tool coerces strings to
// int/float/bool when possible; coercion failures land the field in
// missing_fields rather than the entities map.
//
// Test: extract_entities_test.go covers happy path, missing optional
// field, missing required field surfaces in missing_fields, malformed
// JSON retry, second-attempt failure, type coercion (string→int,
// string→bool), unknown field type rejected at args validation.
package tools
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// extractEntitiesMaxInputBytes is the hard input cap.
const extractEntitiesMaxInputBytes = 32 * 1024
// extractEntitiesFallbackMaxPerRun is the per-run cap when
// ExtractEntitiesConfig is nil.
const extractEntitiesFallbackMaxPerRun = 10
// ExtractEntitiesConfig is the narrow per-deployment config surface
// extract_entities reads at execute time.
type ExtractEntitiesConfig interface {
MaxPerRun(ctx context.Context) int
}
// extractField is one row in the schema the agent supplies. The four
// supported types match the JSON-shape primitives we can validate +
// coerce reliably.
//
// Why an enum-shaped Type field (vs free-form): we need to know how
// to validate the LLM's reply. Free-form ("integer", "Number",
// "boolean") would invite typos that silently miss the validation.
type extractField struct {
Name string `json:"name" description:"Field name to populate (e.g. 'author', 'year_published'). Becomes a key in the returned entities object."`
Description string `json:"description" description:"Short description of what to extract (e.g. 'the book author', 'the year the article was published'). Helps the model find the right value."`
Type string `json:"type" description:"One of: 'string', 'int', 'float', 'bool', 'list_of_strings'. Determines how the LLM's reply is validated and coerced."`
Required bool `json:"required,omitempty" description:"When true, a missing/uncoercible value lands in missing_fields rather than skipping silently."`
}
// extractEntitiesArgs is the LLM-facing param struct.
type extractEntitiesArgs struct {
Text string `json:"text" description:"The text to extract from. Required. Capped at 32KB."`
Fields []extractField `json:"fields" description:"Schema describing what to extract. Each field has name, description, type, and optional required flag."`
}
type extractEntitiesResult struct {
Entities map[string]any `json:"entities,omitempty"`
MissingFields []string `json:"missing_fields,omitempty"`
ModelUsed string `json:"model_used,omitempty"`
RawReply string `json:"raw_reply,omitempty"`
Error string `json:"error,omitempty"`
BudgetMsg string `json:"budget_message,omitempty"`
}
// validExtractTypes is the closed set of Type strings the tool
// accepts. Anything else is rejected at args validation.
var validExtractTypes = map[string]bool{
"string": true,
"int": true,
"float": true,
"bool": true,
"list_of_strings": true,
}
// NewExtractEntities constructs the extract_entities tool.
func NewExtractEntities(helper *llmmeta.Helper, cfg ExtractEntitiesConfig, budget SearchBudget) tool.Tool {
return tool.NewGatedTool[extractEntitiesArgs](
"extract_entities",
"Extract structured fields from unstructured text via a fast LLM. Caller supplies a schema (each field has name + description + type + required); tool returns an entities object with values matching the requested types. Types: string, int, float, bool, list_of_strings. Counts against per-run and 7-day cost budgets.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"llm-meta", "cost-bearing"},
},
func(ctx context.Context, inv tool.Invocation, args extractEntitiesArgs) (string, error) {
if helper == nil {
return "", fmt.Errorf("extract_entities: not configured")
}
text := args.Text
if strings.TrimSpace(text) == "" {
return marshalExtractEntities(extractEntitiesResult{Error: "text is empty"}), nil
}
if len(args.Fields) == 0 {
return marshalExtractEntities(extractEntitiesResult{Error: "fields is empty"}), nil
}
// Validate each field's Type before paying for an LLM
// call.
for _, f := range args.Fields {
if strings.TrimSpace(f.Name) == "" {
return marshalExtractEntities(extractEntitiesResult{Error: "field with empty name"}), nil
}
if !validExtractTypes[strings.ToLower(strings.TrimSpace(f.Type))] {
return marshalExtractEntities(extractEntitiesResult{
Error: fmt.Sprintf("field %q has unsupported type %q (allowed: string|int|float|bool|list_of_strings)", f.Name, f.Type),
}), nil
}
}
if len(text) > extractEntitiesMaxInputBytes {
text = text[:extractEntitiesMaxInputBytes]
}
// Per-run budget gate.
if budget == nil {
maxPerRun := extractEntitiesFallbackMaxPerRun
if cfg != nil {
maxPerRun = cfg.MaxPerRun(ctx)
}
budget = NewInMemorySearchBudget(map[string]int{
"extract_entities": maxPerRun,
})
}
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "extract_entities")
if exceeded {
return marshalExtractEntities(extractEntitiesResult{
Error: "extract_entities_budget_exceeded",
BudgetMsg: fmt.Sprintf("per-run extract_entities budget exceeded (%d/%d). Ask an admin to raise skills.extract_entities.max_per_run.", count, max),
}), nil
}
systemPrompt := "You extract structured data from unstructured text. Return ONLY valid JSON with the requested keys. If a value is not present in the text, omit the key. Do NOT invent values."
userPrompt := buildExtractPrompt(text, args.Fields)
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
Tier: "fast",
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
MaxOutputTokens: 4096,
ResponseFormat: "json",
RetryOnMalformedJSON: true,
ToolName: "extract_entities",
RunID: inv.RunID,
SkillID: inv.SkillID,
CallerID: inv.CallerID,
})
if callErr != nil {
return "", callErr
}
if !res.Success {
kind := res.ErrorKind
if kind == "" {
kind = "llm_unavailable"
}
return marshalExtractEntities(extractEntitiesResult{Error: kind}), nil
}
// Second-failure malformed JSON (success=true but parsed
// is nil and ErrorKind=malformed_json). Surface the raw
// reply so the agent can salvage.
if res.ErrorKind == llmmeta.ErrorKindMalformedJSON || res.Parsed == nil {
return marshalExtractEntities(extractEntitiesResult{
Error: "extraction_failed",
RawReply: res.Text,
ModelUsed: res.ModelUsed,
}), nil
}
parsedMap, ok := res.Parsed.(map[string]any)
if !ok {
return marshalExtractEntities(extractEntitiesResult{
Error: "extraction_failed_not_object",
RawReply: res.Text,
ModelUsed: res.ModelUsed,
}), nil
}
entities, missing := coerceExtractedEntities(parsedMap, args.Fields)
return marshalExtractEntities(extractEntitiesResult{
Entities: entities,
MissingFields: missing,
ModelUsed: res.ModelUsed,
}), nil
},
)
}
// buildExtractPrompt composes the user message describing the schema
// + source text.
func buildExtractPrompt(text string, fields []extractField) string {
var sb strings.Builder
sb.WriteString("Extract the following fields from the text below. Return a JSON object with the field names as keys.\n\nFields:\n")
for _, f := range fields {
fmt.Fprintf(&sb, "- %s (%s): %s", f.Name, f.Type, f.Description)
if f.Required {
sb.WriteString(" [required]")
}
sb.WriteString("\n")
}
sb.WriteString("\nText:\n")
sb.WriteString(text)
return sb.String()
}
// coerceExtractedEntities walks the LLM's response, validating + (when
// possible) coercing each value to the requested type. Required fields
// missing or uncoercible land in missing[]; optional fields silently
// drop.
func coerceExtractedEntities(parsed map[string]any, fields []extractField) (map[string]any, []string) {
entities := make(map[string]any, len(fields))
var missing []string
for _, f := range fields {
raw, present := parsed[f.Name]
if !present || raw == nil {
if f.Required {
missing = append(missing, f.Name)
}
continue
}
value, ok := coerceFieldValue(raw, f.Type)
if !ok {
if f.Required {
missing = append(missing, f.Name)
}
continue
}
entities[f.Name] = value
}
return entities, missing
}
// coerceFieldValue attempts to convert raw to the requested type.
// Returns (value, true) on success or (nil, false) on failure.
//
// Why coerce (vs strict reject): LLMs frequently reply with strings
// that contain numbers ("42") or pseudo-booleans ("yes"). Strict
// rejection would force every author to clean the response themselves.
// Coercion is conservative — string "42" → int 42 succeeds; string
// "forty-two" → int 42 fails (the agent never asked for word-form
// parsing).
func coerceFieldValue(raw any, fieldType string) (any, bool) {
switch strings.ToLower(strings.TrimSpace(fieldType)) {
case "string":
switch v := raw.(type) {
case string:
return v, true
case float64:
return strconv.FormatFloat(v, 'f', -1, 64), true
case bool:
return strconv.FormatBool(v), true
}
return nil, false
case "int":
switch v := raw.(type) {
case float64:
// JSON numbers are float64 by default.
if v == float64(int64(v)) {
return int64(v), true
}
return nil, false
case string:
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
return n, true
}
// Try float-string-with-zero-fractional ("42.0").
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil && f == float64(int64(f)) {
return int64(f), true
}
}
return nil, false
case "float":
switch v := raw.(type) {
case float64:
return v, true
case string:
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
return f, true
}
}
return nil, false
case "bool":
switch v := raw.(type) {
case bool:
return v, true
case string:
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "true", "yes", "1", "y":
return true, true
case "false", "no", "0", "n":
return false, true
}
case float64:
return v != 0, true
}
return nil, false
case "list_of_strings":
switch v := raw.(type) {
case []any:
out := make([]string, 0, len(v))
for _, e := range v {
if s, ok := e.(string); ok {
out = append(out, s)
} else {
// Mixed-type lists fail the type contract.
return nil, false
}
}
return out, true
case string:
// Single-string can be lifted into a one-element list.
return []string{v}, true
}
return nil, false
}
return nil, false
}
func marshalExtractEntities(r extractEntitiesResult) string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
}
return string(b)
}
+49
View File
@@ -0,0 +1,49 @@
// file_storage.go declares the narrow FileStorage interface that the
// four v4 file tools (file_save, file_get, file_list, file_delete)
// need at execute time.
//
// Why a narrow interface (vs importing pkg/logic/skills directly): same
// cycle constraint as kv_storage.go — pkg/logic/skills imports
// pkg/skilltools, so we mirror the FileMeta shape here and let
// pkg/logic/mort.go adapt at wiring time.
//
// FileDomainMeta is field-for-field with skills.FileMeta; the production
// adapter is a struct copy.
package tools
import (
"context"
"errors"
"time"
)
// FileStorage is the narrow surface file tools need from the skills
// package. Production wiring (mort.go) bridges *skills.System.Storage().
// nil-safe: tools constructed against a nil FileStorage surface "not
// configured" at the first call.
type FileStorage interface {
FileSave(ctx context.Context, meta FileDomainMeta, content []byte) (string, error)
FileGet(ctx context.Context, fileID string) (*FileDomainMeta, []byte, error)
FileList(ctx context.Context, skillID, scope string) ([]FileDomainMeta, error)
FileDelete(ctx context.Context, fileID string) error
FileUsageBytes(ctx context.Context, skillID string) (int64, error)
}
// FileDomainMeta mirrors skills.FileMeta. Field-for-field; the
// production adapter is a struct copy.
type FileDomainMeta struct {
ID string // UUID, the public file_id
SkillID string
Scope string
Name string
ContentHash string // SHA256 hex
MimeType string
SizeBytes int64
CreatedAt time.Time
}
// ErrFileNotFound mirrors skills.ErrFileNotFound. The production
// adapter returns this sentinel when wrapping a skills.ErrFileNotFound;
// tools detect it with errors.Is to surface a "not_found" string to the
// LLM rather than a generic error.
var ErrFileNotFound = errors.New("file: not found")
+101
View File
@@ -0,0 +1,101 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// nowParams is the LLM-facing param struct for current_time / now.
//
// Why optional `timezone`: most agent prompts know the user's local
// timezone (it's in the chatbot's system prompt) but the agent has
// no way to override on a per-call basis. An explicit arg lets a
// research skill ask "what time is it in NYC for the user reading
// this report?" without needing access to a member-config lookup
// tool.
type nowParams struct {
Timezone string `json:"timezone,omitempty" description:"Optional IANA timezone name (e.g. 'America/Chicago', 'Europe/London'). Defaults to the calling user's configured timezone, falling back to UTC."`
}
// nowResponse is the JSON envelope returned to the agent.
//
// Why a structured shape: the v1 tool returned a markdown blob.
// Agents that needed just the year had to substring-parse, which
// fails on locale variations. JSON lets the agent pick the field
// it cares about.
type nowResponse struct {
NowISO string `json:"now_iso"`
NowHuman string `json:"now_human"`
Timezone string `json:"timezone"`
Weekday string `json:"weekday"`
Year int `json:"year"`
Month int `json:"month"`
Day int `json:"day"`
Hour int `json:"hour"`
Minute int `json:"minute"`
Second int `json:"second"`
Warning string `json:"warning,omitempty"`
}
// NewNow constructs the v11 current_time / now tool. The provider
// supplies the calling member's configured timezone (per-user
// localisation). nil falls back to UTC.
//
// V11 keeps the registered tool name "now" for back-compat with the
// existing tool catalog tests AND adds the same tool surface under
// the agent-facing description "current time". The design spec
// called the tool "current_time" but the v1 registry already used
// "now" — switching the registry name would break stored skills'
// `tools` lists. Same name, expanded behaviour.
func NewNow(provider CurrentTimeProvider) tool.Tool {
return tool.NewGatedTool[nowParams](
"now",
"Return the current time. Optional 'timezone' (IANA name e.g. 'America/Chicago'); defaults to the calling user's configured timezone or UTC. Returns ISO + human-readable formats plus structured year/month/day/weekday for time-relative reasoning. Use this BEFORE assuming a year — the agent's knowledge cut-off may differ from real time.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeGlobal,
SafeForShare: true,
Categories: []string{"utility"},
},
func(ctx context.Context, inv tool.Invocation, p nowParams) (string, error) {
tzName := strings.TrimSpace(p.Timezone)
warning := ""
if tzName == "" && provider != nil {
tzName = provider.UserTimezone(ctx, inv.CallerID)
}
if tzName == "" {
tzName = "UTC"
}
loc, err := time.LoadLocation(tzName)
if err != nil {
warning = fmt.Sprintf("unknown timezone %q; falling back to UTC", tzName)
tzName = "UTC"
loc = time.UTC
}
t := time.Now().In(loc)
out := nowResponse{
NowISO: t.Format(time.RFC3339),
NowHuman: t.Format("Monday, January 2, 2006 at 3:04 PM MST"),
Timezone: tzName,
Weekday: t.Weekday().String(),
Year: t.Year(),
Month: int(t.Month()),
Day: t.Day(),
Hour: t.Hour(),
Minute: t.Minute(),
Second: t.Second(),
Warning: warning,
}
b, mErr := json.Marshal(out)
if mErr != nil {
return "", fmt.Errorf("now: marshal: %w", mErr)
}
return string(b), nil
},
)
}
+97
View File
@@ -0,0 +1,97 @@
package tools
import (
"context"
"sync"
)
// DefaultResearchConfig returns a ResearchConfig pinned to the v11
// design defaults. Production wiring overrides via a convar-aware
// adapter; tests use the defaults directly.
func DefaultResearchConfig() ResearchConfig {
return defaultResearchConfig{}
}
type defaultResearchConfig struct{}
func (defaultResearchConfig) MaxInlineBytes(_ context.Context) int { return 12 * 1024 }
func (defaultResearchConfig) PDFMaxPages(_ context.Context) int { return 50 }
func (defaultResearchConfig) WebSearchEnabled(_ context.Context) bool { return true }
func (defaultResearchConfig) WebSearchMaxPerRun(_ context.Context) int { return 10 }
func (defaultResearchConfig) ReadPageMaxPerRun(_ context.Context) int { return 10 }
func (defaultResearchConfig) VideoMaxPerRun(_ context.Context) int { return 5 }
func (defaultResearchConfig) VerifyURLMaxPerRun(_ context.Context) int { return 20 }
func (defaultResearchConfig) ReadPDFMaxPerRun(_ context.Context) int { return 5 }
func (defaultResearchConfig) HTTPGetMaxPerRun(_ context.Context) int { return 20 }
func (defaultResearchConfig) HTTPPostMaxPerRun(_ context.Context) int { return 20 }
func (defaultResearchConfig) WebSearchAugmentThreshold(_ context.Context) int { return 5 }
// InMemorySearchBudget is the package-default SearchBudget — a
// simple per-(run,kind) counter held in a map. NOT
// production-correct because the map persists across the process
// lifetime; production wiring MUST plug a per-run reset.
//
// Why a default at all: tests want a working SearchBudget without
// rolling their own. Documenting the production-correctness gap
// here keeps the production adapter (in mort.go) honest.
type InMemorySearchBudget struct {
cap map[string]int // by kind; "" means "use Default"
mu sync.Mutex
counts map[string]int // key = runID+"|"+kind
}
// NewInMemorySearchBudget constructs a default SearchBudget. Pass a
// per-kind cap map (e.g. {"web_search": 10, "read_page": 10}); kinds
// missing from the map fall back to maxPerKindDefault.
func NewInMemorySearchBudget(caps map[string]int) *InMemorySearchBudget {
if caps == nil {
caps = map[string]int{}
}
return &InMemorySearchBudget{
cap: caps,
counts: make(map[string]int),
}
}
// CheckAndIncrement implements SearchBudget. Returns the count AFTER
// incrementing on success; the counter is NOT incremented when the
// call would exceed the cap (so a "search_budget_exceeded" rejection
// doesn't burn budget on retry).
func (b *InMemorySearchBudget) CheckAndIncrement(_ context.Context, runID, kind string) (int, int, bool) {
max := b.cap[kind]
if max <= 0 {
max = 10 // safe default
}
b.mu.Lock()
defer b.mu.Unlock()
key := runID + "|" + kind
cur := b.counts[key]
if cur >= max {
return cur, max, true
}
b.counts[key] = cur + 1
return cur + 1, max, false
}
// ResetRun is a test helper: clears the counters for a single run
// across all kinds. Production wiring uses its own per-run lifecycle
// (the executor's RunFinalizer interface).
func (b *InMemorySearchBudget) ResetRun(runID string) {
b.mu.Lock()
defer b.mu.Unlock()
prefix := runID + "|"
for k := range b.counts {
if len(k) > len(prefix) && k[:len(prefix)] == prefix {
delete(b.counts, k)
}
}
}
// StaticTimeProvider is the package-default CurrentTimeProvider —
// returns "" for every member (the tool then falls back to UTC).
// Tests that need a specific timezone wire a one-line struct.
type StaticTimeProvider struct{}
// UserTimezone implements CurrentTimeProvider with a flat fallback to "".
func (StaticTimeProvider) UserTimezone(_ context.Context, _ string) string { return "" }
+332
View File
@@ -0,0 +1,332 @@
// Package tools — research provider plumbing for v11.
//
// This file declares the narrow interfaces v11's research tools
// (web_search, read_page, read_video, read_pdf, verify_url, etc.) need
// at execute time. Production wiring lives in pkg/logic/mort.go and
// closes over the searcher chain, the extractor / chromedp client, the
// PDF extractor, and the yt-dlp wrapper.
//
// Why narrow interfaces (vs importing pkg/logic/searcher / extractor
// directly): the same cycle-break pattern used by KVStorage, FileStorage,
// HTTPConfigProvider — keeps pkg/skilltools/tools free of the wiring
// layer so tests can stub each dependency. Each provider is nil-safe:
// the tool surfaces "not configured" at first call rather than failing
// at registration.
//
// Test: each tool under pkg/skilltools/tools/ wired against these
// interfaces has its own *_test.go using the in-package fakes in
// research_providers_fakes_test.go.
package tools
import (
"context"
"errors"
"time"
)
// PageCache is the narrow surface read_page (and read_pdf) consult to
// avoid re-fetching the same URL within the cache's TTL. Production
// wiring bridges this interface to the legacy *cache.Cache held by
// pkg/logic/query.System so a `.query foo.com` and a
// `.skill query foo.com` for the same URL share one cache slot.
//
// Why a narrow interface (vs importing the cache package directly):
// same cycle-break pattern as KVStorage / FileStorage / CitationStorage
// — keeps pkg/skilltools/tools free of the wiring layer. The legacy
// cache slot key is `sha256(url)`; the production adapter is
// responsible for hashing so this interface stays clean (raw URL in/out)
// and skill-tool authors never need to know the slot shape.
//
// nil-safe: a tool constructed with a nil PageCache simply skips the
// cache layer (always treat Get as a miss; Set is a no-op).
//
// Test: tests pass a fake PageCache that records Get/Set calls and
// returns canned hits. See page_cache_test.go for the read_page hit /
// miss scenarios.
type PageCache interface {
// Get returns the cached body for urlStr and true on hit, or
// (nil, false) on miss. Implementations MUST treat any backing-
// store error as a miss (best-effort, never fail the caller).
Get(ctx context.Context, urlStr string) ([]byte, bool)
// Set writes body under the slot for urlStr with the supplied TTL.
// Implementations MUST swallow backing-store errors (best-effort
// caching is correct: a write failure should not propagate to the
// agent loop).
Set(ctx context.Context, urlStr string, body []byte, ttl time.Duration)
}
// PageCacheTTL is the default TTL applied by tools that consult a
// PageCache. Mirrors the legacy `query.pageCacheTTL` constant
// (1 hour) so a `.query`-warmed slot reads back from a `.skill query`
// (and vice versa) within the same window.
//
// Tools that want a different TTL pass an explicit value to
// PageCache.Set; this constant is the project default the v11 / v-research
// tools all use.
const PageCacheTTL = 1 * time.Hour
// PageExtractor is the narrow surface read_page needs at execute
// time. The production adapter wraps mort's existing extractor
// (Ollama web_fetch first, chromedp fallback on JS-heavy pages).
//
// nil-safe: a tool constructed with a nil PageExtractor surfaces
// "not configured" at first call.
//
// Why: read_page used to be a thin io.ReadAll over the URL — it
// missed JS rendering, didn't honour the v6 page cache, and could
// not surface the underlying provider name. v11 routes through this
// interface so the production wiring (mort.go) can plug in the
// existing query-side extractor without exposing query.Agent.
type PageExtractor interface {
// ExtractPage fetches and extracts readable text from urlStr.
// Returns the extracted body, a final URL (after any redirects
// the extractor followed), the provider name ("ollama" |
// "chromedp" | "ytdlp"), and an error.
//
// The returned body is the FULL extracted text — callers apply
// the v10 byte-vs-reference cap before surfacing to the agent.
//
// bypassCache=true skips any page cache and forces a fresh
// extraction. Default false.
ExtractPage(ctx context.Context, urlStr string, bypassCache bool) (text string, finalURL string, provider string, err error)
}
// VideoTranscriber is the narrow surface read_video needs at
// execute time. Production wiring wraps internal/ytdlp.
//
// nil-safe: tool surfaces "not configured" at first call.
//
// Why a separate interface from PageExtractor: video is a different
// shape (transcript + metadata) and a different binary (yt-dlp).
// Keeping them distinct lets tests stub each independently.
type VideoTranscriber interface {
// ExtractVideoTranscript returns the transcript text and the
// best-effort metadata (title, duration in seconds, channel).
// Implementations MUST return a non-empty transcript or an
// error — empty-transcript success is interpreted by the tool
// as a "transcript_unavailable" failure.
ExtractVideoTranscript(ctx context.Context, urlStr string) (transcript string, meta VideoMeta, err error)
}
// VideoMeta is best-effort metadata returned alongside a video
// transcript. Any field may be empty/zero if the implementation
// could not extract it.
type VideoMeta struct {
Title string
Channel string
DurationSeconds int
}
// PDFFetcher is the narrow surface read_pdf needs at execute time.
// Production wiring uses an HTTP-aware fetcher that HEAD-validates
// content-type before downloading the body.
//
// nil-safe: tool surfaces "not configured" at first call.
//
// Why: a tool that just embedded PDF extraction would couple
// fetching + parsing. Splitting the fetch (allowlist + SSRF +
// HEAD check) from the extract (page-level parsing) keeps each
// step testable and lets the same fetcher serve verify_url one
// day if we want a PDF-aware fast path.
type PDFFetcher interface {
// FetchPDF downloads the PDF at urlStr (after HEAD-validating
// content-type) and returns the raw bytes plus the final URL.
// HEAD-validation rejects a URL whose Content-Type is not a
// PDF mime AND whose path does not end in .pdf.
FetchPDF(ctx context.Context, urlStr string) (body []byte, finalURL string, err error)
}
// PDFExtractor parses PDF bytes into plain text + page count.
// Production wires internal.ExtractPDFText.
//
// Why split from PDFFetcher: tests want to vary the fetch (mock
// server returning bytes) without rebuilding the extractor.
type PDFExtractor interface {
// ExtractPDFText returns the concatenated plain-text content
// of the PDF along with the page count. The caller applies any
// per-page cap and the v10 byte-vs-reference cap on the result.
ExtractPDFText(ctx context.Context, body []byte, maxPages int) (text string, pageCount int, truncated bool, err error)
}
// HEADChecker is the narrow surface verify_url needs at execute
// time. Production wiring uses the same SSRF-pinned transport as
// http_get so the security envelope is consistent.
//
// Why a separate interface (vs reusing HTTPConfigProvider+doHTTP):
// verify_url's contract is simpler — HEAD only, no body bytes
// returned, and the agent only cares about reachable / status /
// final URL / content-type. A bespoke surface lets the production
// adapter optimise for that path (no body buffer, no body close).
type HEADChecker interface {
// HEAD performs a HEAD request against urlStr (with SSRF +
// allowlist enforcement) and returns the final URL after any
// redirects, the HTTP status code, and the Content-Type header.
// Returns reachable=false with a non-nil err for transport
// failures (DNS, TCP, allowlist rejection); reachable=true with
// any HTTP status (including 4xx/5xx) is the success shape —
// the agent decides whether the URL is "real".
HEAD(ctx context.Context, urlStr string) (finalURL string, status int, contentType string, reachable bool, err error)
}
// CitationStorage is the narrow surface cite() needs at execute
// time. Production wires *skills.System.Storage(); tests stub.
//
// nil-safe: tool surfaces "not configured" at first call.
//
// Why a narrow interface (vs importing pkg/logic/skills): same
// cycle constraint as KVStorage / FileStorage. Production adapter
// in mort.go bridges to skills.Storage's RecordCitation /
// ListCitations methods AND a separate URL-history tracker.
//
// Two responsibilities, deliberately separate:
//
// 1. RecordCitation writes a row into skill_run_sources — this is
// the user-visible citations table for the Sources panel and
// CSV export. ONLY rows the agent successfully cited via
// cite() land here.
// 2. RecordURLTouch / GetTouchedURLs maintains a per-run set of
// URLs the agent has interacted with (web_search results,
// read_page input, read_pdf input, read_video input). cite()
// reads this set to reject claims for URLs the agent never
// touched. This set lives in a different table or scope from
// the citations table — it's working state, not a record.
type CitationStorage interface {
// RecordCitation appends one (run_id, url, claim, cited_at)
// row to the citations table (skill_run_sources). cited_at is
// set by the storage layer to time.Now() when zero. The caller
// has already verified the URL is in the touched-URL set
// (via GetTouchedURLs); this method is the persistence step.
RecordCitation(ctx context.Context, runID, url, claim string) error
// RecordURLTouch records that the agent has interacted with
// `url` during `runID`. Called by web_search (per result),
// read_page, read_pdf, and read_video. Idempotent — repeat
// calls for the same (run_id, url) are no-ops at the storage
// layer.
RecordURLTouch(ctx context.Context, runID, url string) error
// GetTouchedURLs returns the set of URLs the run has
// interacted with. Used by cite() to verify that a claim's
// URL is one the agent actually visited. Empty for a fresh
// run — cite() then rejects every claim with
// "url_not_in_run_history".
GetTouchedURLs(ctx context.Context, runID string) (map[string]struct{}, error)
// ListCitations returns all citations recorded for the run, in
// insertion order. Powers the /skills/{id}/runs/{run_id}
// Sources panel.
ListCitations(ctx context.Context, runID string) ([]CitationRow, error)
}
// CitationRow mirrors the skill_run_sources row shape. Fields
// match the spec: run_id is implicit in the query, url + claim are
// what the agent submitted, cited_at is the wall-clock timestamp
// at insert.
type CitationRow struct {
URL string
Claim string
CitedAt int64 // unix-seconds; storage adapter normalises from time.Time
}
// CurrentTimeProvider exposes a "now" + per-user timezone lookup.
// Production wiring closes over the bot's member-config getter.
//
// nil-safe: a tool constructed with a nil provider falls back to
// server-time + UTC (current behaviour of NewNow before v11).
type CurrentTimeProvider interface {
// UserTimezone returns the IANA timezone name configured for
// the given Discord member ID, or "" when the member has no
// timezone configured. Empty fallback is "UTC".
UserTimezone(ctx context.Context, memberID string) string
}
// SearchBudget is the narrow surface web_search reads at execute
// time to honour skills.web_search.max_per_run.
//
// Production wiring closes over a per-run counter held by the
// executor. nil-safe: tool falls back to a built-in package
// counter (process-wide, NOT per-run) — useful for tests but NOT
// production-correct because budget bleeds across runs. The
// production adapter MUST be wired.
type SearchBudget interface {
// CheckAndIncrement returns the current count AFTER incrementing
// for the given runID, the configured max, and an error when
// the call would exceed the cap. The handler returns a clean
// "search_budget_exceeded" string on exceed (not an error so
// the agent can react).
CheckAndIncrement(ctx context.Context, runID, kind string) (count, max int, exceeded bool)
}
// ResearchConfig is the narrow surface that read_page / read_video /
// read_pdf / verify_url read at execute time for per-tool budget caps
// and inline-vs-file_id thresholds. Production wiring closes over
// the relevant convars.
//
// nil-safe: tools fall back to package defaults.
type ResearchConfig interface {
// MaxInlineBytes returns the cap above which extracted text is
// persisted as a file_id under run-scope (v10 byte-vs-reference
// principle). Default 12 KiB.
MaxInlineBytes(ctx context.Context) int
// PDFMaxPages returns the cap on pages extracted from a PDF
// before truncation. Default 50.
PDFMaxPages(ctx context.Context) int
// WebSearchEnabled is the master switch for web_search.
WebSearchEnabled(ctx context.Context) bool
// WebSearchMaxPerRun is the per-run search cap.
WebSearchMaxPerRun(ctx context.Context) int
// ReadPageMaxPerRun is the per-run page-read cap.
ReadPageMaxPerRun(ctx context.Context) int
// VideoMaxPerRun is the per-run video-read cap.
VideoMaxPerRun(ctx context.Context) int
// VerifyURLMaxPerRun is the per-run HEAD-check cap.
VerifyURLMaxPerRun(ctx context.Context) int
// ReadPDFMaxPerRun is the per-run PDF-read cap.
ReadPDFMaxPerRun(ctx context.Context) int
// HTTPGetMaxPerRun (v15.2) is the per-run http_get cap. The agent
// otherwise can retry-storm through random URLs and bloat its own
// context with each tool result. Default 20.
HTTPGetMaxPerRun(ctx context.Context) int
// HTTPPostMaxPerRun (v15.2) is the per-run http_post cap. Default 20.
HTTPPostMaxPerRun(ctx context.Context) int
// WebSearchAugmentThreshold is the minimum number of primary
// (Ollama) results required to skip the secondary (DDG/Brave)
// search. When the primary backend returns fewer than this many
// results, the augmented searcher also queries the secondary and
// merges both result sets. Default 5.
WebSearchAugmentThreshold(ctx context.Context) int
// ReplyChainDepthMax is unused here; placeholder shape for
// future per-tool caps. Kept off this interface — callers reach
// into the convar reader directly when they need it.
}
// ErrPageExtractionFailed is the sentinel returned by a PageExtractor
// when both Ollama and chromedp paths produce empty content.
var ErrPageExtractionFailed = errors.New("page extraction failed: empty content")
// ErrVideoTranscriptUnavailable is the sentinel returned by a
// VideoTranscriber when no captions / transcript could be obtained.
var ErrVideoTranscriptUnavailable = errors.New("video transcript unavailable")
// ErrPDFNotPDF is the sentinel returned by a PDFFetcher when the
// HEAD response indicates a non-PDF content-type AND the URL path
// has no .pdf extension. Surfaces a clean "url_is_not_a_pdf"
// rejection rather than a generic transport error.
var ErrPDFNotPDF = errors.New("url does not serve a PDF")
// ErrPDFEncrypted is returned by a PDFExtractor when the PDF refuses
// extraction because it is password-protected. Surfaces a clean
// "pdf_encrypted" rejection.
var ErrPDFEncrypted = errors.New("pdf is encrypted")
+113
View File
@@ -0,0 +1,113 @@
// scope_validate.go centralises the storage-scope authorisation check
// shared by every v4 KV and file tool. It enforces:
//
// - "skill" — always allowed (the skill's shared, cross-caller area).
// - "user:<callerID>" — allowed if it matches inv.CallerID (or admin).
// - "user:<other>" — allowed only for admin callers.
// - "run:<runID>" — allowed if it matches inv.RunID (or admin).
// - "run:<other>" — allowed only for admin callers.
// - "root_run:<id>" — allowed if it matches inv.RootRunID (or admin):
// the dispatch tree's SHARED scratchpad, readable
// and writable by every run under one root
// (parallel sibling workers coordinate here).
// - any other shape — rejected with a descriptive error.
//
// Why a single helper (vs inline checks in each tool): the parsing rules
// must match exactly across kv_get/set/list/delete and file_save/get/
// list/delete. Centralising them means one place to fix when the
// vocabulary evolves and one place for the test matrix.
//
// Why the isAdmin parameter: the v4 Invocation does NOT carry an
// admin flag — production tools always pass isAdmin=false. The
// parameter exists for tests (which exercise the admin paths) and for a
// future Invocation extension that adds an admin signal without
// breaking this helper's signature.
package tools
import (
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// ValidateScope rejects scope strings the caller is not authorised to
// access. See file-level doc for the exact ruleset.
//
// Why isAdmin is parameterised: tests pass true to verify admin paths;
// production tools currently always pass false because Invocation
// doesn't carry admin status. The gate is "you can access your own
// scope only" until a future extension threads an admin signal through
// the executor.
func ValidateScope(inv tool.Invocation, scope string, isAdmin bool) error {
if scope == "skill" {
return nil
}
if rest, ok := strings.CutPrefix(scope, "user:"); ok {
if rest == "" {
return fmt.Errorf("scope: empty user id after 'user:'")
}
if rest == inv.CallerID {
return nil
}
if isAdmin {
return nil
}
return fmt.Errorf("scope %q: cannot access another user's storage", scope)
}
if rest, ok := strings.CutPrefix(scope, "root_run:"); ok {
if rest == "" {
return fmt.Errorf("scope: empty run id after 'root_run:'")
}
// The dispatch tree's shared scratchpad. Every run in one tree
// carries the same RootRunID (stamped by both executors from the
// dispatchguard chain), so siblings spawned in parallel — even
// ephemeral workers with distinct agent IDs — validate against
// the same scope string. Storage-side, root_run scopes live in
// the shared RootRunKVPartition; this check is the isolation
// boundary between trees.
if rest == inv.RootRunID && inv.RootRunID != "" {
return nil
}
if isAdmin {
return nil
}
return fmt.Errorf("scope %q: cannot access another dispatch tree's storage", scope)
}
if rest, ok := strings.CutPrefix(scope, "run:"); ok {
if rest == "" {
return fmt.Errorf("scope: empty run id after 'run:'")
}
if rest == inv.RunID {
return nil
}
// V10: when this run is a reply continuation, the agent may
// access the PARENT run's scope. The parent's run-scope KV is
// the natural carrier for "ask user a question, save state,
// resume on reply" — without this access, every continuation
// would have to re-derive state from parent_output alone.
// Note: the parent's run-scope is subject to the v4
// auto-purge (24h after parent finished). Long-delayed replies
// will see an empty scope.
if inv.Continuation != nil && rest == inv.Continuation.ParentRunID {
return nil
}
// V14: when this run is invoked via skill_invoke /
// skill_invoke_parallel from a parent skill, the agent may
// access the PARENT run's scope. This is the natural carrier
// for the "scout fans out, parent reads consolidated state"
// pattern that deepresearch uses — research-scout writes its
// touched-URL list under run:<parent_run_id> and the parent
// reads it back during the investigate phase. Without this
// access, every parent/child handoff would have to be
// serialised through tool-result strings.
if inv.ParentRunID != "" && rest == inv.ParentRunID {
return nil
}
if isAdmin {
return nil
}
return fmt.Errorf("scope %q: cannot access another run's storage", scope)
}
return fmt.Errorf("scope %q: unknown shape; expected 'skill', 'user:<id>', 'run:<id>', or 'root_run:<id>'", scope)
}
+243
View File
@@ -0,0 +1,243 @@
// Package tools — v12 summarize.
//
// One fast-tier LLM call: text in → concise text summary out. Either
// `text` or `file_id` (mutually exclusive) supplies the source. Per-run
// budget enforced via the existing v11 SearchBudget surface (kind=
// "summarize"); per-skill cost accounting via the meta-LLM helper's
// ledger (skill_llm_meta_calls).
//
// Why a dedicated tool (vs reusing summary_summarise): summary_
// summarise wraps the URL-summary pipeline used by /summary; it's
// over-coupled to a specific extraction flow. v12's summarize is the
// "given any text, give me a summary" primitive that downstream tools
// (read_page → summarize, extract → summarize) can compose freely.
//
// File-id input path: when the caller supplies file_id, we dereference
// via FileStorage. Cross-skill check rejects stolen IDs (matching
// file_get's pattern). Scope check denies user:bob's file from alice's
// invocation.
//
// Test: summarize_test.go covers happy path (mock helper), file_id
// input, oversize input truncation, budget exceeded, focus-arg
// pass-through, cross-skill file_id rejection, and the
// missing-both-args validation.
package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
"gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// summarizeMaxInputBytes is the hard input cap. Inputs longer than
// this are truncated with a `truncated=true` flag in the response so
// the agent knows the summary covers a prefix.
const summarizeMaxInputBytes = 32 * 1024
// summarizeDefaultMaxWords is the default max_words when the caller
// doesn't supply one. Capped further by skills.summarize.max_words.
const summarizeDefaultMaxWords = 200
// summarizeFallbackMaxWords is the cap used when SummarizeConfig is nil.
const summarizeFallbackMaxWords = 1000
// summarizeFallbackMaxPerRun is the per-run cap used when SummarizeConfig
// is nil.
const summarizeFallbackMaxPerRun = 10
// SummarizeConfig is the narrow per-run + per-deployment config surface
// summarize reads at execute time. Production wires a closure over the
// `skills.summarize.*` convars; nil falls back to package defaults.
type SummarizeConfig interface {
MaxPerRun(ctx context.Context) int
MaxWords(ctx context.Context) int
}
// summarizeArgs is the LLM-facing param struct.
//
// Why two source fields (text + file_id) with exactly-one validation:
// the agent often produces large content via read_page / read_pdf and
// stores it as a file_id (per the v10 byte-vs-reference principle);
// forcing it to round-trip through a string would defeat the file_id
// pattern. Inline `text` is the simpler path for short snippets.
type summarizeArgs struct {
Text string `json:"text,omitempty" description:"The text to summarise. Either 'text' OR 'file_id' is required (not both). Capped at 32KB; longer inputs truncate with truncated=true in the result."`
FileID string `json:"file_id,omitempty" description:"Alternative to 'text': summarise the contents of a saved file (from read_page/read_pdf/file_save). Must belong to this skill."`
MaxWords int `json:"max_words,omitempty" description:"Maximum word count for the summary. Default 200, capped at skills.summarize.max_words (default 1000)."`
Focus string `json:"focus,omitempty" description:"Optional: what aspect to emphasise (e.g. 'security implications', 'cost analysis', 'main characters')."`
}
type summarizeResult struct {
Summary string `json:"summary"`
WordCount int `json:"word_count"`
ModelUsed string `json:"model_used"`
Truncated bool `json:"truncated,omitempty"`
BudgetMsg string `json:"budget_message,omitempty"`
Error string `json:"error,omitempty"`
}
// NewSummarize constructs the summarize tool. helper / cfg / budget /
// fileStorage may all be nil; the handler surfaces clean errors at
// first call.
func NewSummarize(helper *llmmeta.Helper, cfg SummarizeConfig, budget SearchBudget, fileStorage FileStorage) tool.Tool {
return tool.NewGatedTool[summarizeArgs](
"summarize",
"Produce a concise summary of input text using a fast LLM. Pass either 'text' or 'file_id' (one of them is required). Optional 'focus' steers the summary; 'max_words' caps length (default 200). Counts against per-run and 7-day cost budgets.",
tool.Permission{
AuthoringRequirement: tool.RequirementAnyone,
OperatesOn: tool.ScopeCaller,
SafeForShare: true,
Categories: []string{"llm-meta", "cost-bearing"},
},
func(ctx context.Context, inv tool.Invocation, args summarizeArgs) (string, error) {
if helper == nil {
return "", fmt.Errorf("summarize: not configured")
}
text, truncated, err := loadSummarizeInput(ctx, inv, args, fileStorage)
if err != nil {
return marshalSummarizeResult(summarizeResult{Error: err.Error()}), nil
}
// Per-run budget BEFORE the LLM call so a runaway loop is
// bounded.
if budget == nil {
maxPerRun := summarizeFallbackMaxPerRun
if cfg != nil {
maxPerRun = cfg.MaxPerRun(ctx)
}
budget = NewInMemorySearchBudget(map[string]int{
"summarize": maxPerRun,
})
}
count, max, exceeded := budget.CheckAndIncrement(ctx, inv.RunID, "summarize")
if exceeded {
return marshalSummarizeResult(summarizeResult{
Error: "summarize_budget_exceeded",
BudgetMsg: fmt.Sprintf("per-run summarize budget exceeded (%d/%d). Work with the summaries you already have, or ask an admin to raise skills.summarize.max_per_run.", count, max),
}), nil
}
maxWords := args.MaxWords
if maxWords <= 0 {
maxWords = summarizeDefaultMaxWords
}
cap := summarizeFallbackMaxWords
if cfg != nil {
cap = cfg.MaxWords(ctx)
}
if maxWords > cap {
maxWords = cap
}
systemPrompt := "You produce concise, accurate summaries. Honor the requested word count. Do NOT invent facts."
userPrompt := buildSummarizePrompt(text, maxWords, args.Focus)
res, callErr := helper.Call(ctx, llmmeta.CallSpec{
Tier: "fast",
SystemPrompt: systemPrompt,
UserPrompt: userPrompt,
MaxOutputTokens: maxWords * 8, // ~8 tokens per word upper bound
ResponseFormat: "text",
ToolName: "summarize",
RunID: inv.RunID,
SkillID: inv.SkillID,
CallerID: inv.CallerID,
})
if callErr != nil {
return "", callErr
}
if !res.Success || res.Text == "" {
kind := res.ErrorKind
if kind == "" {
kind = "llm_unavailable"
}
return marshalSummarizeResult(summarizeResult{Error: kind}), nil
}
summary := strings.TrimSpace(res.Text)
return marshalSummarizeResult(summarizeResult{
Summary: summary,
WordCount: countWords(summary),
ModelUsed: res.ModelUsed,
Truncated: truncated,
}), nil
},
)
}
// loadSummarizeInput resolves the input text from either args.Text or
// args.FileID. Exactly one MUST be supplied; both empty AND both
// populated are rejected.
func loadSummarizeInput(ctx context.Context, inv tool.Invocation, args summarizeArgs, fileStorage FileStorage) (string, bool, error) {
hasText := strings.TrimSpace(args.Text) != ""
hasFile := strings.TrimSpace(args.FileID) != ""
if hasText == hasFile {
// Both empty OR both populated.
if !hasText {
return "", false, fmt.Errorf("summarize: one of 'text' or 'file_id' is required")
}
return "", false, fmt.Errorf("summarize: 'text' and 'file_id' are mutually exclusive — pass one")
}
if hasText {
return capInput(args.Text)
}
if fileStorage == nil {
return "", false, fmt.Errorf("summarize: file_id input requires file storage to be configured")
}
meta, content, err := fileStorage.FileGet(ctx, args.FileID)
if err != nil {
if errors.Is(err, ErrFileNotFound) {
return "", false, fmt.Errorf("summarize: file_id not found")
}
return "", false, fmt.Errorf("summarize: file fetch: %w", err)
}
if meta.SkillID != inv.SkillID {
return "", false, fmt.Errorf("summarize: file does not belong to this skill")
}
if err := ValidateScope(inv, meta.Scope, false); err != nil {
return "", false, fmt.Errorf("summarize: %w", err)
}
return capInput(string(content))
}
// capInput truncates input to the hard byte cap, returning the
// (possibly truncated) text and a flag indicating truncation occurred.
func capInput(text string) (string, bool, error) {
if len(text) <= summarizeMaxInputBytes {
return text, false, nil
}
return text[:summarizeMaxInputBytes], true, nil
}
// buildSummarizePrompt composes the user message handed to the LLM.
func buildSummarizePrompt(text string, maxWords int, focus string) string {
var sb strings.Builder
fmt.Fprintf(&sb, "Summarise the following text in at most %d words.", maxWords)
if focus = strings.TrimSpace(focus); focus != "" {
fmt.Fprintf(&sb, " Emphasise: %s.", focus)
}
sb.WriteString("\n\n")
sb.WriteString(text)
return sb.String()
}
// countWords returns a rough word count via whitespace splitting.
// Good enough for the response's word_count column; the agent might
// see slight discrepancies vs the LLM's internal counter, which is
// acceptable.
func countWords(text string) int {
return len(strings.Fields(text))
}
// marshalSummarizeResult serialises a summarizeResult to JSON.
func marshalSummarizeResult(r summarizeResult) string {
b, err := json.Marshal(r)
if err != nil {
return fmt.Sprintf(`{"error":"marshal_failed: %v"}`, err)
}
return string(b)
}
+71 -12
View File
@@ -3,28 +3,87 @@
// A host registers the tools it wants against a tool.Registry, then runs an // A host registers the tools it wants against a tool.Registry, then runs an
// agent whose RunnableAgent.LowLevelTools name them. Tools split two ways: // agent whose RunnableAgent.LowLevelTools name them. Tools split two ways:
// //
// - Always-available, zero-dependency tools (think, ...) need no host backend // - Always-available, zero-configuration tools register via Register (think,
// and register via Register. A light host (gadfly) can call Register and be // now, cite) — all nil-safe, so a light host (gadfly) calls Register and is
// immediately useful. // immediately useful.
// - Backed tools (web search, file/kv storage, summarize, ...) take a nil-safe // - Backed tools take a nil-safe Deps describing their host backend and
// Deps describing their host backend; they register via grouped registrars // register via grouped registrars (RegisterMeta, and RegisterWeb/Store/…
// (RegisterWeb, RegisterStore, ...) as those land. // as they land). Each Deps ships sensible defaults so "some setup" is small.
// //
// Every tool ships with the same three-stage permission model as mort's, and a // A host adds its own domain tools against the SAME registry.
// host adds its own domain tools against the SAME registry.
package tools package tools
import "gitea.stevedudenhoeffer.com/steve/executus/tool" import (
"context"
"errors"
// Register adds the always-available, zero-dependency generic tools to reg "gitea.stevedudenhoeffer.com/steve/executus/llmmeta"
// (currently: think). Returns the first registration error, if any. "gitea.stevedudenhoeffer.com/steve/executus/tool"
)
// Register adds the always-available, zero-configuration generic tools:
//
// - think — record a thought to the run trace (no external effect)
// - now — current time (UTC unless a CurrentTimeProvider is wired)
// - cite — record a source citation (inert unless a CitationStorage is wired)
//
// All are nil-safe. Returns the first registration error.
func Register(reg tool.Registry) error { func Register(reg tool.Registry) error {
for _, t := range []tool.Tool{ return registerAll(reg,
NewThink(), NewThink(),
} { NewNow(nil),
NewCite(nil),
)
}
// MetaDeps wires the LLM-backed meta tools (classify, extract_entities,
// summarize). Helper is required. Budget defaults to an in-memory per-run cap;
// Files is optional (summarize's file_id input is inert without it); MaxPerRun
// and MaxWords default when non-positive.
type MetaDeps struct {
Helper *llmmeta.Helper
Budget SearchBudget
Files FileStorage
MaxPerRun int // per-run cap for each meta tool; default 10
MaxWords int // summarize length cap; default 200
}
// RegisterMeta adds classify, extract_entities, and summarize. It requires a
// configured llmmeta.Helper (the fast-tier meta-LLM caller); everything else
// defaults.
func RegisterMeta(reg tool.Registry, d MetaDeps) error {
if d.Helper == nil {
return errors.New("tools: MetaDeps.Helper is required for the meta tools")
}
if d.Budget == nil {
d.Budget = NewInMemorySearchBudget(nil)
}
if d.MaxPerRun <= 0 {
d.MaxPerRun = 10
}
if d.MaxWords <= 0 {
d.MaxWords = 200
}
cfg := fixedMetaConfig{maxPerRun: d.MaxPerRun, maxWords: d.MaxWords}
return registerAll(reg,
NewClassify(d.Helper, cfg, d.Budget),
NewExtractEntities(d.Helper, cfg, d.Budget),
NewSummarize(d.Helper, cfg, d.Budget, d.Files),
)
}
func registerAll(reg tool.Registry, ts ...tool.Tool) error {
for _, t := range ts {
if err := reg.Register(t); err != nil { if err := reg.Register(t); err != nil {
return err return err
} }
} }
return nil return nil
} }
// fixedMetaConfig satisfies ClassifyConfig / ExtractEntitiesConfig /
// SummarizeConfig with static caps read from MetaDeps.
type fixedMetaConfig struct{ maxPerRun, maxWords int }
func (c fixedMetaConfig) MaxPerRun(context.Context) int { return c.maxPerRun }
func (c fixedMetaConfig) MaxWords(context.Context) int { return c.maxWords }