返回 DeepSeek-Reasonix
planner_gate.go
根目录 / internal / control / planner_gate.go
1 package control
2
3 import (
4 "context"
5 "regexp"
6 "slices"
7 "strings"
8 "unicode"
9 "unicode/utf8"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/runtimepolicy"
13 )
14
15 const (
16 plannerReasonExplicitPlanMode = "explicit_plan_mode"
17 plannerReasonSynthetic = "synthetic"
18 plannerReasonSlash = "slash_command"
19 plannerReasonShortReply = "short_reply"
20 plannerReasonConversation = "conversation"
21 plannerReasonUserDirect = "user_direct"
22 plannerReasonUserPlanOnly = "user_plan_only"
23 plannerReasonUserPlanApproval = "user_plan_for_approval"
24 plannerReasonUserPlanAndExecute = "user_plan_and_execute"
25 plannerReasonContextContinuation = "context_continuation"
26 plannerReasonGoalStart = "explicit_goal_start"
27 plannerReasonDefault = "default_executor"
28 )
29
30 var (
31 directOptionReplyRE = regexp.MustCompile(`(?i)^\s*(?:\d+|[a-z])\s*[.)、。]?\s*$`)
32 prefixedOptionReplyRE = regexp.MustCompile(`(?i)^\s*(?:选|选择|就|用|按|走|执行|choose|pick|use|option|choice|方案)\s*(?:第\s*)?(?:方案|选项|option|choice)?\s*(?:\d+|[一二三四五六七八九十]|[a-z])\s*(?:个|号|项|种|条|方案|option|choice)?\s*[.)、。!!??]?\s*$`)
33 plannerFileRefRE = regexp.MustCompile(`(?i)(?:^|[\s@` + "`" + `"'(])(?:[\w.-]+[/\\])*[\w.-]+\.(?:go|ts|tsx|js|jsx|py|rs|java|kt|md|json|ya?ml|toml|sql|sh|css|html)(?:$|[\s,;:!?,;:!?)` + "`" + `"'])`)
34 )
35
36 type plannerTurnMetadata struct {
37 UserText string
38 Synthetic bool
39 ExplicitPlanMode bool
40 ExplicitGoalStart bool
41 HasConversationContext bool
42 }
43
44 type plannerTurnMetadataKey struct{}
45
46 func withPlannerTurnMetadata(ctx context.Context, meta plannerTurnMetadata) context.Context {
47 return context.WithValue(ctx, plannerTurnMetadataKey{}, meta)
48 }
49
50 func plannerTurnMetadataFromContext(ctx context.Context) (plannerTurnMetadata, bool) {
51 if ctx == nil {
52 return plannerTurnMetadata{}, false
53 }
54 meta, ok := ctx.Value(plannerTurnMetadataKey{}).(plannerTurnMetadata)
55 return meta, ok
56 }
57
58 func (c *Controller) withPlannerTurnMetadata(ctx context.Context, userText string, synthetic bool, priorMessages int) context.Context {
59 text := strings.TrimSpace(agent.StripTransientUserBlocks(userText))
60 constraints := runtimepolicy.ParseConstraints(runtimepolicy.StripQuotedConstraints(text))
61 planMode := c.PlanMode()
62 if planMode {
63 constraints.PlanModeReadOnly = true
64 constraints.ForbidMutation = true
65 }
66 ctx = runtimepolicy.WithContext(ctx, constraints)
67 return withPlannerTurnMetadata(ctx, plannerTurnMetadata{
68 UserText: userText,
69 Synthetic: synthetic,
70 ExplicitPlanMode: planMode,
71 ExplicitGoalStart: c.consumeExplicitGoalStart(),
72 HasConversationContext: priorMessages > 1,
73 })
74 }
75
76 // DecidePlannerRoute applies deterministic precedence rules to a pristine user
77 // turn plus trusted host metadata. It never calls a model and never parses
78 // controller-injected XML to infer host state.
79 func DecidePlannerRoute(ctx context.Context, input string) agent.PlannerDecision {
80 meta, hasMeta := plannerTurnMetadataFromContext(ctx)
81 composedText := strings.TrimSpace(agent.StripTransientUserBlocks(input))
82 text := composedText
83 if hasMeta && strings.TrimSpace(meta.UserText) != "" {
84 text = strings.TrimSpace(meta.UserText)
85 }
86
87 if meta.ExplicitPlanMode || strings.HasPrefix(composedText, PlanModeMarker) {
88 return plannerExecutorDecision(plannerReasonExplicitPlanMode)
89 }
90 // Current turns carry trusted origin metadata. Text recognition is only a
91 // compatibility fallback for direct/legacy callers that have no metadata.
92 if meta.Synthetic || (!hasMeta && IsSyntheticUserMessage(text)) {
93 return plannerExecutorDecision(plannerReasonSynthetic)
94 }
95 if text == "" {
96 return plannerExecutorDecision(plannerReasonConversation)
97 }
98 if strings.HasPrefix(text, "/") {
99 return plannerExecutorDecision(plannerReasonSlash)
100 }
101 if isContextDependentShortReply(text) {
102 return plannerExecutorDecision(plannerReasonShortReply)
103 }
104 if isConversationalTurn(text) {
105 return plannerExecutorDecision(plannerReasonConversation)
106 }
107
108 lower := normalizePlannerText(text)
109 if requestsPlanApproval(lower) {
110 return plannerPlanDecision(agent.PlannerRoutePlanForApproval, plannerReasonUserPlanApproval)
111 }
112 if requestsPlanOnly(lower) {
113 return plannerPlanDecision(agent.PlannerRoutePlanOnly, plannerReasonUserPlanOnly)
114 }
115 if hasLeadingDirective(lower, planAndExecuteDirectives) || hasLeadingDirective(lower, planFirstDirectives) {
116 return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, plannerReasonUserPlanAndExecute)
117 }
118 if requestsDirectExecution(lower) {
119 return plannerExecutorDecision(plannerReasonUserDirect)
120 }
121 if meta.ExplicitGoalStart {
122 return plannerPlanDecision(agent.PlannerRoutePlanAndExecute, plannerReasonGoalStart)
123 }
124 if meta.HasConversationContext && isContextDependentAction(text) {
125 return plannerExecutorDecision(plannerReasonContextContinuation)
126 }
127 return plannerExecutorDecision(plannerReasonDefault)
128 }
129
130 func plannerExecutorDecision(reason string) agent.PlannerDecision {
131 return agent.PlannerDecision{
132 Route: agent.PlannerRouteExecutorOnly,
133 Reason: reason,
134 }
135 }
136
137 func plannerPlanDecision(route agent.PlannerRoute, reason string) agent.PlannerDecision {
138 return agent.PlannerDecision{
139 Route: route,
140 Reason: reason,
141 }
142 }
143
144 func normalizePlannerText(text string) string {
145 text = strings.ToLower(strings.TrimSpace(text))
146 text = strings.ReplaceAll(text, "’", "'")
147 return text
148 }
149
150 func hasLeadingDirective(lower string, directives []string) bool {
151 lower = strings.TrimSpace(lower)
152 for _, polite := range []string{"please ", "please, ", "请", "请先", "麻烦", "麻烦先"} {
153 if after, ok := strings.CutPrefix(lower, polite); ok {
154 lower = strings.TrimSpace(after)
155 break
156 }
157 }
158 for _, directive := range directives {
159 if strings.HasPrefix(lower, directive) {
160 return true
161 }
162 }
163 return false
164 }
165
166 var planAndExecuteDirectives = []string{
167 "先规划再执行", "先规划再实现", "先出方案再执行", "先出方案再实现",
168 "plan first, then", "plan first then", "plan then implement", "plan and implement",
169 }
170
171 var planFirstDirectives = []string{
172 "先规划", "先给方案", "先出方案",
173 "plan first", "draft a plan", "give me a plan", "make a plan",
174 }
175
176 var planOnlyDirectives = []string{
177 "只规划", "只做规划", "只给方案", "只出方案", "给我方案即可",
178 "plan only", "only plan", "just plan", "give me only a plan", "give me a plan only",
179 }
180
181 var planOnlyBoundaryTerms = []string{
182 "give me only a plan", "give me a plan only", "only give me the plan",
183 "给我方案即可", "只要方案",
184 }
185
186 var plannerNoExecutionTerms = []string{
187 "不要执行", "先别执行", "暂不执行", "不要实现", "先别实现", "暂不实现",
188 "不要修改", "先别修改", "不要改代码", "先别改代码", "不要动代码",
189 "do not execute", "don't execute", "do not implement", "don't implement",
190 "do not make changes", "don't make changes", "without executing",
191 "without implementation", "no execution", "no implementation",
192 }
193
194 var plannerApprovalTerms = []string{
195 "等我确认", "等待我确认", "我确认后", "确认后再",
196 "等我批准", "等待我批准", "我批准后", "批准后再",
197 "wait for my approval", "wait for approval", "after i approve", "after my approval",
198 "until i approve", "until my approval", "let me approve", "let me confirm",
199 "after i confirm", "after my confirmation",
200 }
201
202 var directExecutionDirectives = []string{
203 "直接改", "直接修改", "直接做", "直接执行", "别规划", "不要规划", "无需规划",
204 "just do it", "skip the plan",
205 }
206
207 func requestsPlanOnly(lower string) bool {
208 directiveText := plannerDirectiveText(lower)
209 if hasLeadingDirective(directiveText, planOnlyDirectives) {
210 return true
211 }
212 if containsAnyLexical(directiveText, planOnlyBoundaryTerms) {
213 return true
214 }
215 if (strings.Contains(directiveText, "只给") || strings.Contains(directiveText, "只要")) &&
216 containsAnyLexical(directiveText, plannerIntentTerms) {
217 return true
218 }
219 return containsAnyLexical(directiveText, plannerNoExecutionTerms) &&
220 (containsAnyLexical(directiveText, plannerIntentTerms) ||
221 containsAnyLexical(directiveText, plannerWorkTerms))
222 }
223
224 func requestsPlanApproval(lower string) bool {
225 directiveText := plannerDirectiveText(lower)
226 return (containsAnyLexical(directiveText, plannerIntentTerms) ||
227 containsAnyLexical(directiveText, plannerWorkTerms)) &&
228 containsUnnegatedPlannerApproval(directiveText)
229 }
230
231 func requestsDirectExecution(lower string) bool {
232 directiveText := plannerDirectiveText(lower)
233 if containsAnyLexical(directiveText, directExecutionDirectives) {
234 return true
235 }
236 for _, term := range []string{"don't plan", "do not plan"} {
237 offset := 0
238 for offset < len(directiveText) {
239 idx := strings.Index(directiveText[offset:], term)
240 if idx < 0 {
241 break
242 }
243 idx += offset
244 after := strings.TrimSpace(directiveText[idx+len(term):])
245 if !strings.HasPrefix(after, "to ") {
246 return true
247 }
248 offset = idx + len(term)
249 }
250 }
251 return false
252 }
253
254 var plannerIntentTerms = []string{
255 "plan", "planning", "方案", "规划", "计划",
256 }
257
258 func containsUnnegatedPlannerApproval(text string) bool {
259 for _, term := range plannerApprovalTerms {
260 offset := 0
261 for offset < len(text) {
262 idx := strings.Index(text[offset:], term)
263 if idx < 0 {
264 break
265 }
266 idx += offset
267 if !plannerApprovalNegated(text[:idx]) {
268 return true
269 }
270 offset = idx + len(term)
271 }
272 }
273 return false
274 }
275
276 func plannerApprovalNegated(prefix string) bool {
277 prefix = strings.TrimSpace(prefix)
278 for _, negation := range []string{
279 "不要", "不需要", "无需", "无须", "不用", "不必", "别",
280 "do not", "don't", "not", "no need to", "do not need to", "don't need to",
281 "not necessary to", "without",
282 } {
283 if strings.HasSuffix(prefix, negation) {
284 return true
285 }
286 }
287 return false
288 }
289
290 // plannerDirectiveText removes quoted examples before applying execution
291 // boundaries. A user explaining "do not execute" or “别规划” is not issuing
292 // that directive. ASCII apostrophes inside words remain literal, so
293 // contractions such as don't keep matching the directive tables.
294 func plannerDirectiveText(text string) string {
295 var b strings.Builder
296 var closing rune
297 escaped := false
298 runes := []rune(text)
299 for i, r := range runes {
300 if closing != 0 {
301 if escaped {
302 escaped = false
303 b.WriteRune(' ')
304 continue
305 }
306 if (closing == '"' || closing == '`') && r == '\\' {
307 escaped = true
308 b.WriteRune(' ')
309 continue
310 }
311 if r == closing && (closing != '\'' || !plannerInlineApostrophe(runes, i)) {
312 closing = 0
313 }
314 b.WriteRune(' ')
315 continue
316 }
317 switch r {
318 case '"':
319 closing = '"'
320 b.WriteRune(' ')
321 case '“':
322 closing = '”'
323 b.WriteRune(' ')
324 case '‘':
325 // normalizePlannerText converts the closing ’ to ASCII '.
326 closing = '\''
327 b.WriteRune(' ')
328 case '\'':
329 if plannerSingleQuoteStart(runes, i) {
330 closing = '\''
331 b.WriteRune(' ')
332 continue
333 }
334 b.WriteRune(r)
335 case '`':
336 closing = '`'
337 b.WriteRune(' ')
338 default:
339 b.WriteRune(r)
340 }
341 }
342 return b.String()
343 }
344
345 func plannerSingleQuoteStart(runes []rune, i int) bool {
346 if i+1 >= len(runes) || !unicode.IsLetter(runes[i+1]) {
347 return false
348 }
349 return i == 0 || !unicode.IsLetter(runes[i-1]) && !unicode.IsDigit(runes[i-1])
350 }
351
352 func plannerInlineApostrophe(runes []rune, i int) bool {
353 return i > 0 && i+1 < len(runes) &&
354 (unicode.IsLetter(runes[i-1]) || unicode.IsDigit(runes[i-1])) &&
355 (unicode.IsLetter(runes[i+1]) || unicode.IsDigit(runes[i+1]))
356 }
357
358 func isContextDependentAction(text string) bool {
359 text = strings.TrimSpace(text)
360 if text == "" || strings.ContainsAny(text, "\n\r") || utf8.RuneCountInString(text) > 48 {
361 return false
362 }
363 if plannerFileRefRE.MatchString(text) || strings.Contains(text, "@") {
364 return false
365 }
366 lower := normalizePlannerText(text)
367 for _, prefix := range []string{
368 "fix it", "fix this", "do it", "apply it", "make that change", "go ahead with it",
369 "修一下", "改一下", "按这个改", "照这个做", "执行这个", "就这么改", "修复这个问题",
370 } {
371 if strings.HasPrefix(lower, prefix) {
372 return true
373 }
374 }
375 return false
376 }
377
378 func isConversationalTurn(text string) bool {
379 normalized := strings.Trim(strings.ToLower(strings.TrimSpace(text)), " \t\r\n.!?。!?,,;;::")
380 return conversationalTurns[normalized]
381 }
382
383 var conversationalTurns = map[string]bool{
384 "hello": true, "hi": true, "hey": true, "thanks": true, "thank you": true,
385 "你好": true, "您好": true, "谢谢": true, "辛苦了": true, "收到": true, "明白": true,
386 }
387
388 func isContextDependentShortReply(text string) bool {
389 text = strings.TrimSpace(text)
390 if text == "" || strings.ContainsAny(text, "\n\r") {
391 return false
392 }
393 if directOptionReplyRE.MatchString(text) || prefixedOptionReplyRE.MatchString(text) {
394 return true
395 }
396 lower := strings.ToLower(text)
397 if containsAnyLexical(lower, complexIntentTerms) || containsAnyLexical(lower, plannerWorkTerms) {
398 return false
399 }
400 if shortContextReplies[lower] {
401 return true
402 }
403 if utf8.RuneCountInString(text) > 16 {
404 return false
405 }
406 for _, prefix := range shortContextReplyPrefixes {
407 if strings.HasPrefix(lower, prefix) {
408 return true
409 }
410 }
411 return false
412 }
413
414 var shortContextReplies = map[string]bool{
415 "ok": true, "okay": true, "yes": true, "y": true, "no": true, "n": true,
416 "sure": true, "go ahead": true, "proceed": true, "continue": true, "next": true,
417 "sounds good": true, "好": true, "好的": true, "可以": true, "行": true,
418 "嗯": true, "对": true, "是": true, "确认": true, "同意": true, "继续": true,
419 "继续吧": true, "下一步": true, "开始": true, "开始吧": true, "执行": true,
420 "就这样": true, "没问题": true,
421 }
422
423 var shortContextReplyPrefixes = []string{
424 "继续", "执行", "开始", "下一步", "go ahead", "proceed", "continue",
425 }
426
427 func containsAnyLexical(s string, terms []string) bool {
428 for _, term := range terms {
429 if containsLexicalTerm(s, term) {
430 return true
431 }
432 }
433 return false
434 }
435
436 func containsLexicalTerm(s, term string) bool {
437 term = strings.ToLower(strings.TrimSpace(term))
438 if term == "" {
439 return false
440 }
441 if containsNonASCII(term) || strings.ContainsAny(term, " -_/") {
442 return strings.Contains(s, term)
443 }
444 return slices.Contains(strings.FieldsFunc(s, func(r rune) bool {
445 return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_'
446 }), term)
447 }
448
449 func containsNonASCII(s string) bool {
450 for _, r := range s {
451 if r > unicode.MaxASCII {
452 return true
453 }
454 }
455 return false
456 }
457
458 var complexIntentTerms = []string{
459 "refactor", "migrate", "migration", "redesign", "end-to-end", "e2e", "wire up",
460 "integration", "architecture", "release", "package", "重构", "迁移", "改造",
461 "端到端", "联调", "接入", "架构", "发布", "打包",
462 }
463
464 var plannerWorkTerms = []string{
465 "fix", "fixing", "update", "updating", "remove", "removing", "delete", "deleting",
466 "edit", "editing", "write", "writing", "create", "creating", "add", "adding", "repair",
467 "patch", "run", "running", "build", "building", "implement", "implementing", "refactor",
468 "refactoring", "migrate", "migrating", "redesign", "review", "reviewing", "audit",
469 "inspect", "debug", "test", "tests", "testing", "修改", "修复", "更新", "删除", "移除",
470 "编辑", "写入", "创建", "新增", "添加", "运行", "构建", "实现", "重构", "迁移",
471 "改造", "评审", "审查", "排查", "调试", "测试", "加个", "加一", "补一个", "补个",
472 }
473
474 // TaskWarrantsPlanner is retained as a small compatibility predicate for
475 // callers and tests that only need "planner vs executor".
476 func TaskWarrantsPlanner(input string) bool {
477 return DecidePlannerRoute(context.Background(), input).Route != agent.PlannerRouteExecutorOnly
478 }
479
480 // NewPlannerPolicy returns the structured deterministic policy used by the
481 // two-model product path.
482 func NewPlannerPolicy() agent.PlannerPolicy {
483 return DecidePlannerRoute
484 }
485
486 // NewPlannerGate retains the historical bool shape for direct callers.
487 func NewPlannerGate() func(context.Context, string) bool {
488 return func(ctx context.Context, input string) bool {
489 return DecidePlannerRoute(ctx, input).Route != agent.PlannerRouteExecutorOnly
490 }
491 }
492
492 lines GO