diff --git a/CLAUDE.md b/CLAUDE.md index 5a7be76..03e3439 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,11 +58,13 @@ CORE (majordomo + stdlib): structured output — no separate structured/ pkg) llmmeta/ shared meta-LLM helper over model/ [P1 ✓] compact/ context compactor (WithCompactor hook) [P2 ✓] - tools/ generic tool library + Register entrypoint; [P3 wip] - think moved; end-to-end "agent calls a tool" - test green. Remaining: meta/web/net/store/ - compose groups + their nil-safe Deps + default - backends (the default.go grab-bag split) [P3] + 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] BATTERIES (opt-in siblings, each nil-safe + a default): persona/ Agent noun + AgentStore seam + yml loader [P4] diff --git a/tools/cite.go b/tools/cite.go new file mode 100644 index 0000000..53e567e --- /dev/null +++ b/tools/cite.go @@ -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=. +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) +} diff --git a/tools/classify.go b/tools/classify.go new file mode 100644 index 0000000..e2a28d4 --- /dev/null +++ b/tools/classify.go @@ -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\": {\"\": <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) +} diff --git a/tools/extract_entities.go b/tools/extract_entities.go new file mode 100644 index 0000000..92bc07a --- /dev/null +++ b/tools/extract_entities.go @@ -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) +} diff --git a/tools/file_storage.go b/tools/file_storage.go new file mode 100644 index 0000000..2fea928 --- /dev/null +++ b/tools/file_storage.go @@ -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") diff --git a/tools/now.go b/tools/now.go new file mode 100644 index 0000000..051d73e --- /dev/null +++ b/tools/now.go @@ -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 + }, + ) +} diff --git a/tools/research_defaults.go b/tools/research_defaults.go new file mode 100644 index 0000000..a53773f --- /dev/null +++ b/tools/research_defaults.go @@ -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 "" } diff --git a/tools/research_providers.go b/tools/research_providers.go new file mode 100644 index 0000000..d8585d2 --- /dev/null +++ b/tools/research_providers.go @@ -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") diff --git a/tools/scope_validate.go b/tools/scope_validate.go new file mode 100644 index 0000000..e0f27e8 --- /dev/null +++ b/tools/scope_validate.go @@ -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:" — allowed if it matches inv.CallerID (or admin). +// - "user:" — allowed only for admin callers. +// - "run:" — allowed if it matches inv.RunID (or admin). +// - "run:" — allowed only for admin callers. +// - "root_run:" — 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: 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:', 'run:', or 'root_run:'", scope) +} diff --git a/tools/summarize.go b/tools/summarize.go new file mode 100644 index 0000000..99f6a5b --- /dev/null +++ b/tools/summarize.go @@ -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) +} diff --git a/tools/tools.go b/tools/tools.go index 3c0045a..e9f15be 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -3,28 +3,87 @@ // A host registers the tools it wants against a tool.Registry, then runs an // agent whose RunnableAgent.LowLevelTools name them. Tools split two ways: // -// - Always-available, zero-dependency tools (think, ...) need no host backend -// and register via Register. A light host (gadfly) can call Register and be +// - Always-available, zero-configuration tools register via Register (think, +// now, cite) — all nil-safe, so a light host (gadfly) calls Register and is // immediately useful. -// - Backed tools (web search, file/kv storage, summarize, ...) take a nil-safe -// Deps describing their host backend; they register via grouped registrars -// (RegisterWeb, RegisterStore, ...) as those land. +// - Backed tools take a nil-safe Deps describing their host backend and +// register via grouped registrars (RegisterMeta, and RegisterWeb/Store/… +// 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 -// host adds its own domain tools against the SAME registry. +// A host adds its own domain tools against the SAME registry. package tools -import "gitea.stevedudenhoeffer.com/steve/executus/tool" +import ( + "context" + "errors" -// Register adds the always-available, zero-dependency generic tools to reg -// (currently: think). Returns the first registration error, if any. + "gitea.stevedudenhoeffer.com/steve/executus/llmmeta" + "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 { - for _, t := range []tool.Tool{ + return registerAll(reg, 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 { return err } } 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 }