Files
executus/skill/inputs.go
T
steve 41659b2412 P4: skill noun — domain + LEAN SkillStore + ToRunnable + Memory
The skill half of the persona/skill pair, as a clean redesign (not a faithful
lift of mort's 60-method skills.Storage kitchen sink):
- skill.go/skill_version.go/validate.go/inputs.go/schedule.go moved clean; the
  only host couplings severed: llms.IsTierName -> model.IsTierName, and the
  chatbot DefaultChatbotInputName const localized.
- store.go: a DELIBERATELY LEAN SkillStore — skill lifecycle (CRUD + visibility)
  + versioning + scheduling ONLY. The KV/file/quota sub-stores that were fused
  into mort's interface are the tools/ store seams; email/channel grants stay
  host concerns.
- runnable.go: Skill.ToRunnable() lowers a skill into run.RunnableAgent (flat
  tool list, no palette — composition is a host concern); DueAt() helper.
- memory.go: NewMemory() — zero-dep in-process SkillStore (visibility filters,
  newest-first versions).

Tests: ToRunnable mapping, visibility (public/shared/private) listing, version
ordering + lookup. No mort dependency (go.mod tidy clean); core imports ZERO
from skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:40:58 -04:00

423 lines
14 KiB
Go

package skill
import (
"fmt"
"strings"
)
// This file holds the shared input-parsing primitives used by both the
// chatbot exposure adapter (chatbot_provider.go) and the .skill Discord
// command handler (commands.go) to construct a SkillInputs map from
// caller-supplied raw values. Centralising here avoids the two paths
// drifting in their type-coercion or required-check semantics.
//
// Two layers:
//
// - CoerceInputValue: per-param-type coercion (int/float/bool/string).
// Accepts loosely-typed values (LLM-stringified numbers, JSON
// float64s for ints) and returns a value in the target Go shape.
//
// - CoerceInputs: per-skill validation. Walks the InputSchema, coerces
// each declared param via CoerceInputValue, drops extras silently,
// errors on missing required.
//
// Why exported (capital): both consumers live in the same package, but
// the names are also referenced in test files and the symbols are
// genuinely useful API for any future consumer (webui form handler,
// scheduler in v2). Keep the surface small.
// CoerceInputValue coerces a single raw value to the target InputParam
// type. JSON numbers arrive from json.Unmarshal as float64; bools as
// bool; strings as string. Type-mismatched strings are accepted ("3" →
// int 3, "true" → bool true) because both LLM tool calls and Discord
// command args frequently surface scalars as strings.
//
// Why: LLM tool-call args come through json.Unmarshal of a plain
// map[string]any, which forces every JSON number into float64 and every
// JSON string into string. Without this coerce step, an int parameter
// would arrive in SkillInputs as a float64, a bool sent as "true" would
// arrive as a string, etc. — confusing the skill agent's prompt
// renderer and any tool-side logic that switches on Go type. The
// .skill command handler benefits identically: arg tokens arrive as
// strings, but downstream tools may expect typed values.
//
// Test: TestCoerceInputValue in inputs_test.go covers each branch.
func CoerceInputValue(paramType string, v any) (any, error) {
switch paramType {
case "int":
switch x := v.(type) {
case float64:
return int(x), nil
case int:
return x, nil
case string:
var i int
if _, err := fmt.Sscanf(x, "%d", &i); err != nil {
return nil, fmt.Errorf("not an int: %q", x)
}
return i, nil
default:
return nil, fmt.Errorf("not an int: %T", v)
}
case "float":
switch x := v.(type) {
case float64:
return x, nil
case int:
return float64(x), nil
case string:
var f float64
if _, err := fmt.Sscanf(x, "%f", &f); err != nil {
return nil, fmt.Errorf("not a float: %q", x)
}
return f, nil
default:
return nil, fmt.Errorf("not a float: %T", v)
}
case "bool":
switch x := v.(type) {
case bool:
return x, nil
case string:
switch x {
case "true", "True", "TRUE", "1":
return true, nil
case "false", "False", "FALSE", "0":
return false, nil
default:
return nil, fmt.Errorf("not a bool: %q", x)
}
default:
return nil, fmt.Errorf("not a bool: %T", v)
}
default:
// "string", "user", "channel", "url", and unknown — coerce to
// string. JSON numbers/bools are stringified so the executor's
// validateInputs (which strips e.g. <@!123> wrappers) gets a
// uniform string input.
switch x := v.(type) {
case string:
return x, nil
case float64:
return fmt.Sprintf("%v", x), nil
case bool:
return fmt.Sprintf("%v", x), nil
default:
return fmt.Sprintf("%v", v), nil
}
}
}
// CoerceInputs validates and coerces a map of raw caller-supplied values
// against the declared parameter set:
//
// - Extra keys (not in params) are dropped silently.
// - Missing required keys return an error so the caller can surface
// usage information.
// - Per-param type coercion handles int/float/bool sent as strings.
//
// Returns a fresh map containing only declared params; never mutates the
// input map.
//
// Why: see CoerceInputValue. Both callers (chatbot exposure adapter,
// .skill command handler) need the same required-check + extra-drop
// semantics; previously only the chatbot path implemented them, which
// is exactly why .skill <name> <args> dropped its arguments entirely.
//
// Test: TestCoerceInputs in inputs_test.go.
func CoerceInputs(params []InputParam, raw map[string]any) (map[string]any, error) {
out := make(map[string]any, len(params))
for _, p := range params {
v, present := raw[p.Name]
if !present {
if p.Required {
return nil, fmt.Errorf("missing required parameter %q", p.Name)
}
continue
}
typed, err := CoerceInputValue(p.Type, v)
if err != nil {
return nil, fmt.Errorf("parameter %q: %w", p.Name, err)
}
out[p.Name] = typed
}
return out, nil
}
// ParseCommandInputs parses a free-form command argument string into a
// raw map[string]any keyed by InputSchema parameter names. Three modes
// are supported, picked by the shape of `schema`:
//
// CASE A — empty schema:
// The whole string becomes {"request": "<rest>"}. Mirrors the
// chatbot exposure default (DefaultChatbotInputName) so a skill with
// no declared inputs can still receive its trigger text uniformly
// across both surfaces.
//
// CASE B — exactly one required param (with optional non-required
// tail):
// If the user passed any --key=value or --key value flags they're
// parsed as flags (Case C). Otherwise the WHOLE rest-of-message
// becomes that single required param's value. This is the
// "single-arg convenience" pattern that lets `.skill weather Boston
// today` work without the user typing --city=.
//
// CASE C — multiple params, OR any --flag style input:
// Tokens are parsed as `--name=value` or `--name value`. Bare
// positional tokens after a flag are collected as that flag's value.
// Trailing positional tokens with no preceding flag are dropped
// (the caller's usage string should mention the flag form).
//
// The returned map values are RAW strings (or bool true for
// presence-only flags); type coercion is the caller's job via
// CoerceInputs.
//
// Why this signature instead of returning the typed map directly: the
// caller wants to distinguish "missing required" (→ usage reply) from
// "type coercion failed" (→ explicit error). Splitting parse from
// coerce keeps the message specific.
func ParseCommandInputs(schema []InputParam, raw string) map[string]any {
out := map[string]any{}
raw = strings.TrimSpace(raw)
if raw == "" {
return out
}
// Detect flag-style input regardless of schema shape — even a single
// required-param schema may be invoked via `.skill x --name value`
// for forward compat.
hasFlag := strings.Contains(raw, "--")
switch {
case len(schema) == 0:
// Empty schema: mirror the chatbot exposure adapter's default
// "request" pseudo-param so executor.composePrompt can render
// it uniformly.
out[DefaultChatbotInputName] = raw
case !hasFlag && countRequired(schema) == 1:
// Single-required-param convenience: whole rest-of-message is the
// value, regardless of any other (non-required) params declared.
// They can be supplied via --flag form if needed.
req := firstRequired(schema)
out[req.Name] = raw
default:
// Flag-style parse. Walk tokens looking for --name[=value] or
// --name <value>.
parseFlagStyle(out, schema, raw)
}
return out
}
// countRequired returns the number of params marked Required.
func countRequired(schema []InputParam) int {
n := 0
for _, p := range schema {
if p.Required {
n++
}
}
return n
}
// firstRequired returns the first required param. Caller must have
// already verified at least one exists.
func firstRequired(schema []InputParam) *InputParam {
for i := range schema {
if schema[i].Required {
return &schema[i]
}
}
return nil
}
// parseFlagStyle walks tokens for --name=value and --name value forms.
// Unknown flags (not in schema) are still accepted into the output map
// so the caller can detect and warn about them; CoerceInputs will drop
// extras when constructing the final SkillInputs.
//
// Tokens not preceded by a --flag are dropped. v1 is intentionally
// strict-ish here: we don't try to guess which positional token belongs
// to which param when there are several. The single-required-param
// convenience handles the common ambiguity-free case in the caller.
func parseFlagStyle(out map[string]any, schema []InputParam, raw string) {
tokens := tokeniseCommandLine(raw)
declared := map[string]bool{}
for _, p := range schema {
declared[p.Name] = true
}
i := 0
for i < len(tokens) {
t := tokens[i]
if !strings.HasPrefix(t, "--") {
// Bare positional token outside a flag context — drop. The
// caller's usage string should steer users to flag form.
i++
continue
}
key := t[2:]
// --name=value form
if eq := strings.IndexByte(key, '='); eq >= 0 {
out[key[:eq]] = key[eq+1:]
i++
continue
}
// --name <value> form: take the next token IF it doesn't itself
// start with --. Otherwise treat as a presence-only boolean flag.
if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "--") {
out[key] = tokens[i+1]
i += 2
continue
}
out[key] = "true"
i++
}
_ = declared // reserved for v2 unknown-flag warnings
}
// tokeniseCommandLine splits a free-form Discord command argument
// string into tokens. Quoted spans (single or double quotes) are kept
// as one token so users can pass values with spaces:
//
// .skill weather --city="New York"
// .skill summarise --text 'a long sentence here'
//
// Mirrors the user's intuition without introducing a full shell
// parser. Newlines split as whitespace.
func tokeniseCommandLine(s string) []string {
var out []string
var cur strings.Builder
var quote rune
flush := func() {
if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
for _, r := range s {
switch {
case quote != 0:
if r == quote {
quote = 0
continue
}
cur.WriteRune(r)
case r == '"' || r == '\'':
quote = r
case r == ' ' || r == '\t' || r == '\n':
flush()
default:
cur.WriteRune(r)
}
}
flush()
return out
}
// ResolveCommandInputs is the one-call helper a Discord .skill handler
// uses to turn a free-form rest-of-message into a coerced
// SkillInputs map ready to hand to the executor. It is the single
// production entry point for command-side input resolution: every
// caller must use it (do NOT chain ParseCommandInputs + CoerceInputs
// directly).
//
// Why this exists as a single function: chaining
// ParseCommandInputs + CoerceInputs at the call site is what broke
// `.skill echo hello world` in production. ParseCommandInputs Case A
// (empty schema) writes the user's text into out["request"], but
// CoerceInputs(emptySchema, …) iterates the DECLARED params and
// silently drops every key not in the schema — so "request" is
// dropped before reaching the executor, and the agent's user-prompt
// renders "(no input provided)". The fix is to mirror the chatbot
// exposure adapter: derive the EFFECTIVE param set (which inflates
// an empty schema to a single required "request" param) and coerce
// against that, not the original empty schema.
//
// What:
// - Empty input_schema → effective params = [{request, required, string}],
// so ParseCommandInputs Case A's "request" key survives Coerce.
// - Non-empty input_schema → effective params = the schema as-is, so
// Case B / Case C parse-and-coerce semantics are unchanged.
//
// Returns the coerced SkillInputs map, or an error suitable for
// surfacing to the user (e.g. via FormatUsage). Never mutates
// `schema`.
//
// Test: TestResolveCommandInputs_* in inputs_test.go cover the three
// cases plus the empty-schema regression.
func ResolveCommandInputs(schema []InputParam, raw string) (map[string]any, error) {
rawInputs := ParseCommandInputs(schema, raw)
effective := effectiveCommandParams(schema)
return CoerceInputs(effective, rawInputs)
}
// effectiveCommandParams returns the parameter set the .skill command
// path should use for coercion. Mirrors chatbotToolParams in
// chatbot_provider.go: an empty input_schema is inflated to a single
// required "request" string param so the user's free-text trigger
// survives CoerceInputs's drop-extras semantics.
//
// Why a separate helper (vs reusing chatbotToolParams): keeping the
// helper local to inputs.go avoids dragging chatbot_provider.go into
// the .skill command path's import surface and makes the intent
// (Discord-side parameter inflation) explicit at the call site.
func effectiveCommandParams(schema []InputParam) []InputParam {
if len(schema) > 0 {
return schema
}
return []InputParam{{
Name: DefaultChatbotInputName,
Description: "The user's free-text trigger.",
Type: "string",
Required: true,
}}
}
// FormatUsage renders a human-readable usage string for the .skill
// invocation form. Used by command handlers when required params are
// missing or coercion fails.
//
// Why: keep the usage message in one place so both the missing-required
// and coercion-failed paths produce identical output.
func FormatUsage(name string, schema []InputParam) string {
var sb strings.Builder
fmt.Fprintf(&sb, "usage: `.skill %s", name)
if len(schema) == 0 {
sb.WriteString(" <text>`")
return sb.String()
}
if countRequired(schema) == 1 {
req := firstRequired(schema)
fmt.Fprintf(&sb, " <%s>`", req.Name)
// Show optional flags (if any).
var optional []InputParam
for _, p := range schema {
if !p.Required {
optional = append(optional, p)
}
}
if len(optional) > 0 {
sb.WriteString("\n optional:")
for _, p := range optional {
fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type)
}
}
return sb.String()
}
// Multi-param: full --flag form.
for _, p := range schema {
if p.Required {
fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type)
}
}
for _, p := range schema {
if !p.Required {
fmt.Fprintf(&sb, " [--%s=<%s>]", p.Name, p.Type)
}
}
sb.WriteString("`")
return sb.String()
}