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
+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
},
)
}