| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "unicode/utf8" |
| 6 | ) |
| 7 | |
| 8 | // heuristicInputIsTask reports whether a user input reads as an actionable |
| 9 | // task rather than conversational chat. The delivery evidence gate uses it to |
| 10 | // decide when a turn should be held to acceptance-criteria expectations |
| 11 | // (deliveryTaskNeedsEvidence); greetings and acknowledgements must not arm the |
| 12 | // delivery gates. |
| 13 | func heuristicInputIsTask(input string) bool { |
| 14 | trimmed := strings.TrimSpace(input) |
| 15 | if trimmed == "" { |
| 16 | return false |
| 17 | } |
| 18 | |
| 19 | normalized := strings.ToLower(strings.Trim(trimmed, " \t\r\n.!?。!?,,;;::")) |
| 20 | |
| 21 | // Very short greeting/acknowledgement whitelist (1-3 words). |
| 22 | shortGreetings := []string{ |
| 23 | "hello", "hi", "hey", "你好", "您好", "nihao", |
| 24 | "thanks", "thank you", "谢谢", "谢了", |
| 25 | "ok", "okay", "好的", "嗯", "行", |
| 26 | "got it", "i see", "明白", "了解", "收到", "我知道了", "先不用", |
| 27 | } |
| 28 | |
| 29 | words := strings.Fields(normalized) |
| 30 | if len(words) <= 3 { |
| 31 | for _, greeting := range shortGreetings { |
| 32 | if normalized == greeting { |
| 33 | return false |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // Polite acknowledgements can contain action words from the completed |
| 39 | // task ("thanks for fixing") but should stay conversational. |
| 40 | chatPhrases := []string{ |
| 41 | "thanks for", "thank you for", "i'll check later", "i will check later", |
| 42 | "i'll test it later", "i will test it later", "that test was helpful", "the test was helpful", |
| 43 | "谢谢你", "辛苦了", |
| 44 | } |
| 45 | for _, phrase := range chatPhrases { |
| 46 | index := strings.Index(normalized, phrase) |
| 47 | if index < 0 { |
| 48 | continue |
| 49 | } |
| 50 | // Acknowledgement wording only short-circuits a purely conversational |
| 51 | // turn. Preserve a real task before it or after an explicit transition, |
| 52 | // e.g. "thanks for fixing that; now update the tests". |
| 53 | if prefix := strings.TrimSpace(normalized[:index]); prefix != "" && heuristicInputHasStrongTaskSignal(prefix) { |
| 54 | return true |
| 55 | } |
| 56 | if deliveryTaskHasFollowUpAfterChat(normalized[index+len(phrase):]) { |
| 57 | return true |
| 58 | } |
| 59 | return false |
| 60 | } |
| 61 | // Ambiguous prose stays conversational. Delivery evidence gates require a |
| 62 | // concrete host-observable signal rather than using message length as a |
| 63 | // proxy; explicit mutations, files, commands, failures, and audit verbs are |
| 64 | // still classified below. |
| 65 | return heuristicInputHasStrongTaskSignal(normalized) |
| 66 | } |
| 67 | |
| 68 | func heuristicInputHasStrongTaskSignal(input string) bool { |
| 69 | normalized := strings.ToLower(strings.TrimSpace(input)) |
| 70 | // File references and concrete commands are strong task signals. Shared |
| 71 | // parsing keeps email addresses and remote product names from accidentally |
| 72 | // arming the delivery gate while covering ordinary repository file types. |
| 73 | if deliveryTaskHasFileReference(normalized) || deliveryTaskHasCommand(normalized) { |
| 74 | return true |
| 75 | } |
| 76 | // Mutation intent has a richer, negation-aware vocabulary than this generic |
| 77 | // task heuristic. Reuse it so short requests such as "push the branch" do not |
| 78 | // bypass delivery gates merely because the two keyword lists drift apart. |
| 79 | if deliveryTaskHasMutationIntent(normalized) || deliveryTaskNeedsPersistentAction(normalized) { |
| 80 | return true |
| 81 | } |
| 82 | |
| 83 | // Failure/help descriptions are actionable even when phrased without an |
| 84 | // imperative verb, e.g. "the auth isn't working". Shared fault signals keep |
| 85 | // task recognition and Goal budget classification from drifting apart. |
| 86 | if taskInputHasFaultSignal(normalized) { |
| 87 | return true |
| 88 | } |
| 89 | helpPhrases := []string{ |
| 90 | "can you help", "help with", "cannot", "can't", |
| 91 | "无法", "不能", |
| 92 | } |
| 93 | for _, phrase := range helpPhrases { |
| 94 | if strings.Contains(normalized, phrase) { |
| 95 | return true |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // Action keyword detection. |
| 100 | actionNeedles := []string{ |
| 101 | "fix", "debug", "repair", "resolve", "reproduce", |
| 102 | "create", "add", "write", "edit", "update", "change", "delete", "remove", "rename", |
| 103 | "review", "inspect", "analyze", "check", "audit", "verify", "test", "run", "build", "implement", "refactor", "modify", "patch", "replace", |
| 104 | "configure", "upgrade", "downgrade", "enable", "disable", "merge", "make changes", "make a change", "make the changes", |
| 105 | "make the requested changes", "make the necessary changes", "make these changes", "make those changes", "make code changes", |
| 106 | "continue work", "continue the", "continue this", |
| 107 | "修复", "调试", "解决", "复现", "创建", "新建", "添加", "编写", "编辑", "修改", "更新", |
| 108 | "删除", "移除", "重命名", "评审", "检查", "分析", "审计", "验证", "测试", "运行", "构建", "实现", "重构", "继续处理", |
| 109 | "调整", "替换", "移动", "升级", "降级", "启用", "禁用", "合并", "改动", "打补丁", |
| 110 | "看看", "看下", "帮我看", "帮我看下", "处理下", "处理一下", "排查", "定位", |
| 111 | } |
| 112 | |
| 113 | for _, needle := range actionNeedles { |
| 114 | if containsTaskNeedle(normalized, needle) { |
| 115 | return true |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | return false |
| 120 | } |
| 121 | |
| 122 | func deliveryTaskHasFollowUpAfterChat(input string) bool { |
| 123 | for index, current := range input { |
| 124 | switch current { |
| 125 | case '.', ',', ';', '!', '?', '。', ',', ';', '!', '?': |
| 126 | candidate := strings.TrimSpace(input[index+utf8.RuneLen(current):]) |
| 127 | if candidate != "" && heuristicInputHasStrongTaskSignal(candidate) { |
| 128 | return true |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | for _, cue := range []string{ |
| 133 | " but ", " however ", " nevertheless ", " now ", " then ", " and ", " please ", " so ", " therefore ", |
| 134 | "但是", "但请", "不过", "现在", "然后", "所以", "请", "继续", "再", |
| 135 | } { |
| 136 | for rest := input; ; { |
| 137 | index := strings.Index(rest, cue) |
| 138 | if index < 0 { |
| 139 | break |
| 140 | } |
| 141 | candidate := strings.TrimSpace(rest[index+len(cue):]) |
| 142 | if candidate != "" && heuristicInputHasStrongTaskSignal(candidate) { |
| 143 | return true |
| 144 | } |
| 145 | rest = rest[index+len(cue):] |
| 146 | } |
| 147 | } |
| 148 | return false |
| 149 | } |
| 150 | |
| 151 | func containsTaskNeedle(input, needle string) bool { |
| 152 | if needle == "" { |
| 153 | return false |
| 154 | } |
| 155 | if containsNonASCII(needle) || strings.Contains(needle, " ") { |
| 156 | return strings.Contains(input, needle) |
| 157 | } |
| 158 | for _, word := range strings.FieldsFunc(input, func(r rune) bool { |
| 159 | return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '_' |
| 160 | }) { |
| 161 | if word == needle { |
| 162 | return true |
| 163 | } |
| 164 | } |
| 165 | return false |
| 166 | } |
| 167 | |
| 168 | func containsNonASCII(s string) bool { |
| 169 | for _, r := range s { |
| 170 | if r > 127 { |
| 171 | return true |
| 172 | } |
| 173 | } |
| 174 | return false |
| 175 | } |
| 176 |