mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* perf(tools): add explicit tool effect metadata Introduce fail-closed EffectClass and ToolCapabilities on every built-in tool so a later concurrent read-only batch PR can decide safety from explicit metadata rather than tool names. Plugin and MCP tools stay EffectUnknown. No concurrent execution is enabled. * fix(tools): address review feedback on effect metadata Harden capability normalization and resource keys before PR6 consumes them: - Force ThreadSafe=false for every non-ReadOnly effect at read time - Reject duplicate builtin names in ValidateBuiltinCatalog - Coerce numeric session ids and accept "session" for process keys - Strip path/query before userinfo in endpoint host parsing - Wire apply_patch keys from unified-diff paths + cwd - Reclassify bash as WorkspaceWrite (one-shot shell mutator) - Expand regression tests for keys, normalization, and gates * fix(tools): keep multiFileResourceKeys used by apply_patch keys Satisfies unused lint after switching apply_patch to applyPatchResourceKeys. * fix(tools): resolve CodeRabbit residual capability/key issues - Join apply_patch diff paths under cwd for conflict keys - Validate constructor-declared capabilities before normalization - Unify session/process ID key extraction helper --------- Co-authored-by: KRATOS <[email protected]>
314 lines
12 KiB
Go
314 lines
12 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// AskUserQuestion is one clarifying question the agent wants the user to answer.
|
|
// Options/MultiSelect are presentation hints for an interactive front-end; the
|
|
// tool itself never blocks on input.
|
|
type AskUserQuestion struct {
|
|
Question string
|
|
Header string
|
|
Options []string
|
|
OptionDescriptions []string
|
|
Recommended string
|
|
MultiSelect bool
|
|
}
|
|
|
|
// askUserNonInteractiveMessage is returned both by the tool's own Run() fallback
|
|
// and by the agent loop when no interactive user is wired up, so the model gets
|
|
// identical, actionable guidance in either path.
|
|
const askUserNonInteractiveMessage = "No interactive user is available to answer questions. " +
|
|
"Proceed with your best assumption, explicitly stating the assumptions you are making."
|
|
|
|
type askUserTool struct {
|
|
baseTool
|
|
}
|
|
|
|
// NewAskUserTool builds the ask_user tool. It is read-only (it gathers input,
|
|
// never mutates the workspace). The agent loop intercepts ask_user calls and
|
|
// routes them to an interactive front-end when one exists; this tool's Run() is
|
|
// the fallback used when nothing intercepts the call (e.g. headless runs).
|
|
func NewAskUserTool() *askUserTool {
|
|
return &askUserTool{
|
|
baseTool: baseTool{
|
|
name: "ask_user",
|
|
description: "Ask the user one or more clarifying questions and wait for their answers. " +
|
|
"Use ONLY for genuinely blocking ambiguity that you cannot resolve from the workspace or reasonable assumptions. " +
|
|
"When the answer is likely one of a small set, you MAY include 2-4 suggested `options` and mark one as `recommended` " +
|
|
"(it must match one of the options) — an interactive front-end shows these as a quick picker with a \"type my own\" fallback. " +
|
|
"Options are optional: omit them for open-ended questions. " +
|
|
"If no interactive user is available, this returns guidance to proceed with your best assumption instead of blocking.",
|
|
parameters: Schema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"header": {
|
|
Type: "string",
|
|
Description: "Optional short heading shown above the questions.",
|
|
},
|
|
"questions": {
|
|
Type: "array",
|
|
Description: "One or more questions to ask the user.",
|
|
MinItems: intPtr(1),
|
|
Items: &PropertySchema{
|
|
Type: "object",
|
|
Properties: map[string]PropertySchema{
|
|
"question": {Type: "string", Description: "The non-empty question to ask the user.", MinLength: intPtr(1)},
|
|
"header": {
|
|
Type: "string",
|
|
Description: "Optional short title (2-3 words) used as this question's tab label in a multi-question prompt.",
|
|
},
|
|
"options": {
|
|
Type: "array",
|
|
Description: "Optional list of 2-4 suggested answer choices (string labels) for a quick picker.",
|
|
Items: &PropertySchema{Type: "string"},
|
|
},
|
|
"optionDescriptions": {
|
|
Type: "array",
|
|
Description: "Optional one-line descriptions aligned by index to options.",
|
|
Items: &PropertySchema{Type: "string"},
|
|
},
|
|
"recommended": {
|
|
Type: "string",
|
|
Description: "Optional recommended choice; must match one of options. Preselected as the default in the picker.",
|
|
},
|
|
"multiSelect": {
|
|
Type: "boolean",
|
|
Description: "Whether multiple options may be selected (defaults to false).",
|
|
},
|
|
},
|
|
Required: []string{"question"},
|
|
},
|
|
},
|
|
},
|
|
Required: []string{"questions"},
|
|
AdditionalProperties: false,
|
|
},
|
|
safety: readOnlySafety("Asks the user clarifying questions; gathers input only."),
|
|
// EffectInteractive: blocks on user input; ordering matters.
|
|
capabilities: ToolCapabilities{Effect: EffectInteractive, ThreadSafe: false},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run is the fallback path: it is only reached when nothing intercepted the call
|
|
// (no interactive user). It validates the arguments so a malformed call still
|
|
// gets useful feedback, then tells the model to proceed with its best assumption.
|
|
// It never blocks on input.
|
|
func (tool *askUserTool) Run(_ context.Context, args map[string]any) Result {
|
|
if _, err := ParseAskUserQuestions(args); err != nil {
|
|
return errorResult("Error: Invalid arguments for ask_user: " + err.Error())
|
|
}
|
|
return okResult(askUserNonInteractiveMessage)
|
|
}
|
|
|
|
// AskUserNonInteractiveMessage exposes the shared graceful-degradation message so
|
|
// the agent loop and the tool fallback stay in lock-step.
|
|
func AskUserNonInteractiveMessage() string {
|
|
return askUserNonInteractiveMessage
|
|
}
|
|
|
|
// ParseAskUserQuestions extracts the questionnaire from raw tool arguments. It is
|
|
// shared by the tool's Run() fallback and the agent loop's interactive path so
|
|
// both validate identically.
|
|
func ParseAskUserQuestions(args map[string]any) ([]AskUserQuestion, error) {
|
|
raw, ok := args["questions"]
|
|
if !ok || raw == nil {
|
|
return nil, fmt.Errorf("questions is required")
|
|
}
|
|
items, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("questions must be an array")
|
|
}
|
|
if len(items) == 0 {
|
|
return nil, fmt.Errorf("questions must contain at least one question")
|
|
}
|
|
|
|
questions := make([]AskUserQuestion, 0, len(items))
|
|
for index, item := range items {
|
|
// Weak models sometimes send a question as a bare string instead of an
|
|
// object — accept that as a free-text question.
|
|
if text, ok := item.(string); ok {
|
|
if strings.TrimSpace(text) == "" {
|
|
return nil, fmt.Errorf("question %d must not be empty", index+1)
|
|
}
|
|
questions = append(questions, AskUserQuestion{Question: text})
|
|
continue
|
|
}
|
|
object, ok := item.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf("question %d must be an object or string", index+1)
|
|
}
|
|
text, err := questionTextArg(object)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("question %d %s", index+1, err.Error())
|
|
}
|
|
// multiSelect is a UI hint; treat an uncoercible value as the default rather
|
|
// than failing the whole call (mirrors the best-effort options path).
|
|
multiSelect, _ := boolArg(object, "multiSelect", false)
|
|
options, optionDescriptions := coerceAskUserOptions(object["options"], object["optionDescriptions"]) // best-effort; never errors
|
|
questions = append(questions, AskUserQuestion{
|
|
Question: text,
|
|
Header: firstStringKey(object, "header"),
|
|
Options: options,
|
|
OptionDescriptions: optionDescriptions,
|
|
Recommended: recommendedOption(object["recommended"], options), // only kept if it matches an option
|
|
MultiSelect: multiSelect,
|
|
})
|
|
}
|
|
return questions, nil
|
|
}
|
|
|
|
// questionTextArg reads the question text, accepting common key variants used by
|
|
// weaker models. It enforces a non-empty trimmed string but, unlike
|
|
// aliasedStringArg, treats a present-but-non-string or blank value as "not
|
|
// present" and falls through to the next alias (question text is best-effort
|
|
// across spellings, not type-strict).
|
|
func questionTextArg(object map[string]any) (string, error) {
|
|
for _, key := range []string{"question", "prompt", "text", "q", "title"} {
|
|
if v, ok := object[key]; ok && v != nil {
|
|
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
|
return s, nil
|
|
}
|
|
}
|
|
}
|
|
return "", fmt.Errorf("question is required")
|
|
}
|
|
|
|
// recommendedOption returns the model's recommended choice ONLY when it resolves to
|
|
// one of the supplied options (exact, then case-insensitive trim match). It returns
|
|
// the canonical option text (not the model's raw spelling) so the front-end can
|
|
// match it by equality, and "" when there is no usable recommendation — keeping the
|
|
// invariant that Recommended is always either empty or a member of Options.
|
|
func recommendedOption(value any, options []string) string {
|
|
raw, ok := value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || len(options) == 0 {
|
|
return ""
|
|
}
|
|
for _, option := range options {
|
|
if option == raw {
|
|
return option
|
|
}
|
|
}
|
|
for _, option := range options {
|
|
if strings.EqualFold(strings.TrimSpace(option), raw) {
|
|
return option
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// firstStringKey returns the first non-empty string value among the given keys.
|
|
func firstStringKey(object map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := object[key]; ok {
|
|
if s, ok := value.(string); ok && strings.TrimSpace(s) != "" {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// coerceAskUserOptions extracts option labels and their aligned descriptions from
|
|
// the raw "options" value without ever failing (options are presentation hints, so
|
|
// a malformed shape must not break the call). Each option may be a bare string (no
|
|
// description) or an object {label/value/text, description/desc/detail}. The
|
|
// question-level "optionDescriptions" array (descsValue) fills any description left
|
|
// empty, aligned by index. The returned descriptions slice is nil when every entry
|
|
// is empty, so a plain options list stays single-line.
|
|
func coerceAskUserOptions(optionsValue, descsValue any) (labels []string, descriptions []string) {
|
|
if items, ok := optionsValue.([]any); ok {
|
|
for _, item := range items {
|
|
switch value := item.(type) {
|
|
case string:
|
|
if s := strings.TrimSpace(value); s != "" {
|
|
labels = append(labels, s)
|
|
descriptions = append(descriptions, "")
|
|
}
|
|
case map[string]any:
|
|
label := firstStringKey(value, "label", "value", "text", "name", "title", "option")
|
|
if label == "" {
|
|
continue
|
|
}
|
|
labels = append(labels, label)
|
|
descriptions = append(descriptions, firstStringKey(value, "description", "desc", "detail", "hint", "subtitle"))
|
|
}
|
|
}
|
|
} else {
|
|
// Non-array shape (e.g. newline-delimited string): reuse the shared coercion.
|
|
labels = coerceStringSlice(optionsValue)
|
|
descriptions = make([]string, len(labels))
|
|
}
|
|
// Fill any still-empty descriptions from the parallel optionDescriptions array.
|
|
parallel := coerceStringSlice(descsValue)
|
|
for index := range labels {
|
|
if index < len(descriptions) && strings.TrimSpace(descriptions[index]) != "" {
|
|
continue
|
|
}
|
|
if index < len(parallel) {
|
|
for len(descriptions) <= index {
|
|
descriptions = append(descriptions, "")
|
|
}
|
|
descriptions[index] = parallel[index]
|
|
}
|
|
}
|
|
for len(descriptions) < len(labels) {
|
|
descriptions = append(descriptions, "")
|
|
}
|
|
for _, description := range descriptions {
|
|
if strings.TrimSpace(description) != "" {
|
|
return labels, descriptions
|
|
}
|
|
}
|
|
return labels, nil
|
|
}
|
|
|
|
// FormatAskUserAnswers renders question/answer pairs into a clear, model-readable
|
|
// block. Missing answers are surfaced explicitly so the model never silently
|
|
// treats an unanswered question as answered.
|
|
//
|
|
// It distinguishes two shapes of "empty" the model would otherwise conflate: a
|
|
// wholesale dismissal (the user closed the prompt without answering ANYTHING) is
|
|
// flagged up front as a skip, so the model doesn't invent a default; a single
|
|
// blank field amid other answers is marked "(left blank)".
|
|
func FormatAskUserAnswers(questions []AskUserQuestion, answers []string) string {
|
|
anyAnswered := false
|
|
for index := range questions {
|
|
if index < len(answers) && strings.TrimSpace(answers[index]) != "" {
|
|
anyAnswered = true
|
|
break
|
|
}
|
|
}
|
|
|
|
lines := make([]string, 0, len(questions)*3+2)
|
|
if len(questions) > 0 && !anyAnswered {
|
|
lines = append(lines, "[note] The user dismissed this prompt without answering. Treat this as a skip: do not assume a default — ask again more specifically, or proceed only if the intent is already unambiguous.")
|
|
lines = append(lines, "")
|
|
}
|
|
for index, question := range questions {
|
|
answer := ""
|
|
if index < len(answers) {
|
|
answer = strings.TrimSpace(answers[index])
|
|
}
|
|
if answer == "" {
|
|
if anyAnswered {
|
|
answer = "(left blank)"
|
|
} else {
|
|
answer = "(skipped)"
|
|
}
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. [question] %s", index+1, question.Question))
|
|
lines = append(lines, "[answer] "+answer)
|
|
lines = append(lines, "")
|
|
}
|
|
return strings.TrimSpace(strings.Join(lines, "\n"))
|
|
}
|