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 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": ""}. 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 . 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 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(" `") 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() }