c8559676ed
executus CI / test (push) Has been cancelled
Merges the skill half of the persona/skill pair plus the second nested module. (Squashed onto main from phase-4b-skill; the audit/budget/persona batteries it was stacked on already landed via the P4 merge.) - skill/: clean-redesign Skill noun + LEAN SkillStore (lifecycle/versions/ schedule only) + ToRunnable + Memory default. - contrib/store/: separate go.mod carrying modernc.org/sqlite, so the driver never enters the core go.sum. db.Budget()/Personas()/Skills()/Audit() back all four store seams (JSON-blob + indexed columns; round-trip tested). Includes the verified gadfly #5 fixes (AppendVersion tx+UNIQUE+error, Mark*ScheduledRun atomic json_set, busy_timeout, NaN guard). - CI: builds + tests the nested module and asserts it owns the sqlite driver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
423 lines
14 KiB
Go
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()
|
|
}
|