返回 DeepSeek-Reasonix
input.go
根目录 / internal / control / input.go
1 package control
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "unicode"
8
9 "reasonix/internal/ablation"
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 goaldomain "reasonix/internal/goal"
13 "reasonix/internal/memory"
14 "reasonix/internal/planmode"
15 "reasonix/internal/skill"
16 )
17
18 // InvocationRequest is an explicit user-selected Skill or Subagent entity.
19 // Offset is used only to preserve the visual order chosen in the composer.
20 type InvocationRequest struct {
21 Name string `json:"name"`
22 Kind string `json:"kind"`
23 Offset int `json:"offset"`
24 }
25
26 // PlanModeMarker is prepended to every user turn while plan mode is on. It rides
27 // in the user message (not the system prompt or tools), so the cache-stable
28 // prompt prefix is left untouched and the toggle costs nothing in cache hits.
29 const PlanModeMarker = planmode.Marker
30
31 // legacyPlanModeMarker is retained only so transcripts written by older builds
32 // can be displayed without their injected workflow prefix.
33 const legacyPlanModeMarker = "[Plan mode — read-only. Explore the codebase first (read_file, ls, grep, glob, web_fetch, task, ask are available; writers are refused by the harness). Before planning, if a decision that is genuinely the user's — tech stack, an ambiguous requirement, scope, an irreversible choice — would materially shape the plan and you can't settle it from the codebase or a sensible default, use the ask tool to clarify it first; otherwise pick the obvious default and state the assumption in the plan instead of asking. Then present a LAYERED plan as your reply and stop — do not write files, edit, or run side-effecting bash. Structure the plan as a two-level markdown list so it becomes a layered task list: each PHASE is a top-level numbered list item (a coherent milestone, e.g. \"1. Add the config loader\"), and each phase's concrete, verifiable sub-steps are bullets indented beneath it (e.g. \" - parse the TOML into Config\"). Use plain numbered list items for phases — do NOT write phases as markdown headings (##, ###) — so both levels parse. Keep phases few (about 2-6). The user will be asked to approve before any changes are made.]"
34
35 const (
36 activeGoalOpen = "<active-goal>"
37 activeGoalClose = "</active-goal>"
38 hookContextTag = "hook-context"
39 )
40
41 const (
42 maxHookContextChars = 10000
43 maxTotalHookContextChars = 20000
44 )
45
46 const (
47 GoalStatusRunning = "running"
48 GoalStatusComplete = "complete"
49 GoalStatusBlocked = "blocked"
50 GoalStatusStopped = "stopped"
51 )
52
53 type GoalResearchMode int
54
55 const (
56 GoalResearchAuto GoalResearchMode = iota
57 GoalResearchOn
58 GoalResearchOff
59 )
60
61 // StripComposePrefixes removes controller-injected prefixes from a composed
62 // user message so that the display text matches what the user actually typed.
63 // It strips the PlanModeMarker plus transient XML blocks such as
64 // <reasoning-language>, <memory-update>, and <background-jobs> that Compose
65 // prepends to user turns. This is used as a fallback when no .display.json
66 // sidecar recording exists (e.g. sessions created before the display-recording
67 // feature, or synthetic user messages injected by the controller).
68 func StripComposePrefixes(content string) string {
69 // The plan marker is prepended after the transient blocks, so a block can
70 // become leading only after the marker strips: iterate to a fixpoint.
71 s := content
72 for range 4 {
73 next := agent.StripTransientUserBlocks(s)
74 next = stripComposeMarker(next, PlanModeMarker)
75 next = stripComposeMarker(next, legacyPlanModeMarker)
76 next = stripComposeMarker(next, planmode.LegacyWorkflowMarker)
77 if next == s {
78 break
79 }
80 s = next
81 }
82 return strings.TrimSpace(s)
83 }
84
85 func stripComposeMarker(s, marker string) string {
86 s = strings.TrimPrefix(s, marker+"\n\n")
87 return strings.TrimPrefix(s, marker)
88 }
89
90 // StripReferencedContextPrefix removes the "Referenced context:" preamble and
91 // the trailing XML reference blocks (<file>, <dir>, <resource>, <image>) that
92 // controller.ResolveRefs injects when the user @-references files or resources.
93 // The user's actual input follows the reference blocks after a blank line.
94 // Used for title generation and previews so the displayed text matches what
95 // the user typed, not the injected context preamble (#4954).
96 func StripReferencedContextPrefix(content string) string {
97 const preamble = "Referenced context:"
98 s := strings.TrimSpace(content)
99 if !strings.HasPrefix(s, preamble) {
100 return content
101 }
102 // Skip past the preamble.
103 s = strings.TrimSpace(s[len(preamble):])
104 // Skip past all XML reference blocks: <file ...>...</file>, <dir ...>...</dir>,
105 // <resource ...>...</resource>, <image ...>...</image>.
106 for {
107 s = strings.TrimSpace(s)
108 if s == "" {
109 return ""
110 }
111 // Check for a reference block start.
112 if !strings.HasPrefix(s, "<file ") && !strings.HasPrefix(s, "<dir ") &&
113 !strings.HasPrefix(s, "<resource ") && !strings.HasPrefix(s, "<image ") {
114 break
115 }
116 // Find the matching close tag.
117 tagEnd := strings.IndexByte(s, ' ')
118 if tagEnd < 0 {
119 break
120 }
121 tagName := s[1:tagEnd]
122 closeTag := "</" + tagName + ">"
123 closeIdx := strings.Index(s, closeTag)
124 if closeIdx < 0 {
125 break
126 }
127 s = strings.TrimSpace(s[closeIdx+len(closeTag):])
128 }
129 return s
130 }
131
132 // IsSyntheticUserMessage returns true if the content matches one of the known
133 // synthetic user messages injected by the controller or agent loop (plan
134 // approval, stream recovery, legacy readiness markers, etc.). These should not
135 // be shown in the chat UI; the legacy marker is not a control-flow trigger for
136 // ordinary Standard/Delivery turns.
137 func IsSyntheticUserMessage(content string) bool {
138 if trimmed := strings.TrimSpace(agent.StripTransientUserBlocks(content)); trimmed == planApprovedMessage {
139 return true
140 }
141 // The prefix list lives in internal/agent (agent.SyntheticUserPrefixes) so
142 // preview/title/turn-count derivations there share the exact same filter
143 // (#3653).
144 return agent.IsSyntheticUserText(content)
145 }
146
147 // Compose applies the plan-mode marker to a turn's text when plan mode is on,
148 // returning the message to actually send to the model. The frontend keeps
149 // showing the raw text as the user bubble.
150 func (c *Controller) Compose(text string) string {
151 return c.compose(text, text, true)
152 }
153
154 func (c *Controller) compose(text, source string, includeHookContext bool) string {
155 goal, goalStatus := c.goals.snapshot()
156 if c.sessionEngineEnabled() {
157 if view, err := c.goalLifecycleView(); err == nil && view != nil {
158 goal = view.Objective
159 if view.Phase == goaldomain.PhaseActive && view.Activation == goaldomain.ActivationArmed {
160 goalStatus = GoalStatusRunning
161 } else {
162 goalStatus = GoalStatusStopped
163 }
164 if includeHookContext {
165 if recovery, recoveryErr := goaldomain.RecoveryPrompt(*view); recoveryErr == nil {
166 text = recovery + "\n\n" + text
167 }
168 }
169 } else {
170 goal, goalStatus = "", GoalStatusStopped
171 }
172 }
173 return c.composeWithGoal(
174 text,
175 source,
176 includeHookContext,
177 goal,
178 goalStatus,
179 )
180 }
181
182 func (c *Controller) composeWithGoal(
183 text, source string,
184 includeHookContext bool,
185 goal, goalStatus string,
186 ) string {
187 c.mu.Lock()
188 plan := c.sessionSettings.planMode
189 responseLanguage := c.responseLanguage
190 reasoningLanguage := c.reasoningLanguage
191 c.mu.Unlock()
192 notes := c.memory.drainPending()
193
194 if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
195 prefix := activeGoalBlock(goal)
196 text = prefix + "\n\n" + text
197 }
198 if plan {
199 text = PlanModeMarker + "\n\n" + text
200 }
201 text = agent.WithResponseLanguage(text, responseLanguage)
202 text = agent.WithReasoningLanguageForSource(text, reasoningLanguage, source)
203
204 // Memory added mid-session rides the turn (never the cached system prefix),
205 // so it takes effect now without invalidating the prompt cache. It folds into
206 // the system prefix on the next session, where it costs nothing per turn.
207 if len(notes) > 0 {
208 var b strings.Builder
209 b.WriteString("<memory-update>\n")
210 b.WriteString("The following project-memory changes were just made and apply from now on:\n")
211 for _, n := range notes {
212 b.WriteString("- " + n + "\n")
213 }
214 b.WriteString("</memory-update>\n\n")
215 text = b.String() + text
216 }
217
218 // Background jobs that finished since the last turn ride the turn too, so the
219 // model learns of completions even though the user-facing notices don't reach
220 // its context. Like memory, this never touches the cache-stable prefix.
221 if c.jobs != nil {
222 if note := c.jobs.DrainCompletedNoteForSession(c.parentSessionID()); note != "" {
223 text = "<background-jobs>\n" + note + "\n</background-jobs>\n\n" + text
224 }
225 }
226 if includeHookContext {
227 if block := c.drainHookContextBlock(); block != "" {
228 text = block + "\n\n" + text
229 }
230 // Relevant facts ride only the real user-turn tail. This preserves the
231 // stable system/tool prefix and keeps synthetic recovery turns free of
232 // accidental recall. A just-written fact already arrives in memory-update.
233 if len(notes) == 0 && !c.ablation.Off(ablation.Retrieval) {
234 result := c.memory.recall(source)
235 event.RecordMemoryRecall(c.sink, memoryRecallAudit(result))
236 if block := result.Block(); block != "" {
237 text = strings.TrimRight(text, "\n") + "\n\n" + block
238 }
239 } else if len(notes) > 0 {
240 c.memory.recordRecall(memory.RecallResult{
241 Query: strings.TrimSpace(source),
242 Suppressed: "memory update already supplies the new fact",
243 })
244 }
245 }
246 return text
247 }
248
249 // LastMemoryRecall returns the last real turn's automatic-recall decision for
250 // diagnostics and context-management surfaces.
251 func (c *Controller) LastMemoryRecall() memory.RecallResult {
252 return c.memory.lastRecallResult()
253 }
254
255 func (c *Controller) enqueueHookContexts(contexts []string) {
256 if len(contexts) == 0 {
257 return
258 }
259 c.mu.Lock()
260 defer c.mu.Unlock()
261 for _, context := range contexts {
262 context = strings.TrimSpace(context)
263 if context == "" {
264 continue
265 }
266 c.hookContexts = append(c.hookContexts, context)
267 }
268 }
269
270 func (c *Controller) drainHookContextBlock() string {
271 c.mu.Lock()
272 contexts := c.hookContexts
273 c.hookContexts = nil
274 c.mu.Unlock()
275 if len(contexts) == 0 {
276 return ""
277 }
278 var b strings.Builder
279 b.WriteString(`<hook-context event="SessionStart">`)
280 b.WriteString("\n")
281 total := 0
282 for i, context := range contexts {
283 text, truncated := clipHookContext(context, maxHookContextChars)
284 remaining := maxTotalHookContextChars - total
285 if remaining <= 0 {
286 fmt.Fprintf(&b, "[truncated: omitted %d additional hook context item(s)]\n", len(contexts)-i)
287 break
288 }
289 text, totalTruncated := clipHookContext(text, remaining)
290 total += len([]rune(text))
291 if i > 0 {
292 b.WriteString("\n---\n")
293 }
294 b.WriteString(escapeHookContext(text))
295 b.WriteString("\n")
296 if truncated || totalTruncated {
297 b.WriteString("[truncated]\n")
298 }
299 }
300 b.WriteString(`</hook-context>`)
301 return b.String()
302 }
303
304 func clipHookContext(s string, max int) (string, bool) {
305 r := []rune(s)
306 if len(r) <= max {
307 return s, false
308 }
309 if max < 0 {
310 max = 0
311 }
312 return string(r[:max]), true
313 }
314
315 func escapeHookContext(s string) string {
316 return strings.ReplaceAll(s, "</"+hookContextTag+">", "<\\/"+hookContextTag+">")
317 }
318
319 func reasoningLanguageBlock(lang string) string {
320 return agent.ReasoningLanguageBlock(lang)
321 }
322
323 func (c *Controller) ComposeSynthetic(text string) string {
324 c.mu.Lock()
325 responseLang := c.responseLanguage
326 lang := c.reasoningLanguage
327 c.mu.Unlock()
328 text = agent.WithResponseLanguage(text, responseLang)
329 return agent.WithReasoningLanguageForSource(text, lang, text)
330 }
331
332 func activeGoalBlock(goal string) string {
333 goal = strings.TrimSpace(goal)
334 goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>")
335 var b strings.Builder
336 b.WriteString(activeGoalOpen)
337 b.WriteString("\n")
338 b.WriteString(goal)
339 b.WriteString("\n\n")
340 b.WriteString(goalTaskContractInstructions)
341 b.WriteString("\n")
342 b.WriteString(activeGoalClose)
343 return b.String()
344 }
345
346 const goalTaskContractInstructions = `Goal mode: pursue this goal autonomously. Treat the user's goal as a task contract:
347 - Honor Context, Request, Output format, Constraints, and Checkpoint/Pause policy sections when present; otherwise infer a lightweight contract from the conversation and workspace.
348 - Preserve scope and output format. Do not invent requirements or hide uncertainty; state assumptions when sensible defaults are enough to proceed.
349 - Pause only when the next step involves an irreversible or externally visible operation, the requested scope has changed, or progress requires information only the user can provide. Otherwise keep working and report assumptions at the end.
350 - Complete only when the concrete request is done, the output format and constraints are satisfied, and relevant verification was attempted or reported unavailable.
351
352 Do not stop after describing a plan; execute the next useful step. Use get_goal before a lifecycle change and pass its exact ID and revision to update_goal. Mark complete only when the whole objective is done. If useful work remains, leave an active goal unchanged: the host automatically schedules the next round without a continue action. Mark blocked only for a concrete persistent blocker.`
353
354 // MemoryQuickAddNote parses the "# <note>" memory shortcut. The space after
355 // "#" is intentional: "#7", "#issue", and "#标题" are ordinary user prompts,
356 // not memory writes. Multi-line input starting with "# " is NOT treated as a
357 // quick-add note — it is almost certainly a Markdown heading in a structured
358 // prompt (e.g. "# Context\n\n- file.go\n# Objective"). Only single-line input
359 // may be a quick-add note.
360 func MemoryQuickAddNote(input string) (note string, ok bool) {
361 trimmed := strings.TrimSpace(input)
362 if strings.Contains(trimmed, "\n") {
363 return "", false
364 }
365 if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "#\t") {
366 return strings.TrimSpace(trimmed[1:]), true
367 }
368 return "", false
369 }
370
371 // RememberCommandNote parses the explicit "/remember <note>" memory command.
372 func RememberCommandNote(input string) (note string, ok bool) {
373 trimmed := strings.TrimSpace(input)
374 switch {
375 case trimmed == "/remember":
376 return "", true
377 case strings.HasPrefix(trimmed, "/remember ") || strings.HasPrefix(trimmed, "/remember\t"):
378 return strings.TrimSpace(trimmed[len("/remember"):]), true
379 default:
380 return "", false
381 }
382 }
383
384 type GoalCommandAction int
385
386 const (
387 GoalCommandStatus GoalCommandAction = iota + 1
388 GoalCommandSet
389 GoalCommandClear
390 GoalCommandPause
391 GoalCommandResume
392 )
393
394 type GoalCommand struct {
395 Action GoalCommandAction
396 Text string
397 Strict bool
398 ResearchMode GoalResearchMode
399 DeprecatedBudgetFlag bool
400 }
401
402 const GoalBudgetFlagDeprecatedNotice = "This /goal budget flag is deprecated and no longer changes the execution limit; Goal now runs continuously by default."
403
404 func ParseGoalCommand(input string) (GoalCommand, bool) {
405 trimmed := strings.TrimSpace(input)
406 if trimmed != "/goal" && !strings.HasPrefix(trimmed, "/goal ") && !strings.HasPrefix(trimmed, "/goal\t") {
407 return GoalCommand{}, false
408 }
409 args := strings.TrimSpace(trimmed[len("/goal"):])
410 strict, researchMode, actionArgs := parseLeadingGoalFlags(args)
411 deprecatedBudgetFlag := researchMode != GoalResearchAuto
412
413 switch strings.ToLower(actionArgs) {
414 case "", "status":
415 return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true
416 case "clear", "off", "stop", "done":
417 return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true
418 case "pause":
419 return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true
420 case "resume":
421 return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true
422 default:
423 return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode, DeprecatedBudgetFlag: deprecatedBudgetFlag}, true
424 }
425 }
426
427 func parseLeadingGoalFlags(args string) (bool, GoalResearchMode, string) {
428 strict := false
429 mode := GoalResearchAuto
430 rest := strings.TrimLeftFunc(args, unicode.IsSpace)
431 for rest != "" {
432 token, after := leadingGoalToken(rest)
433 switch strings.ToLower(token) {
434 case "--strict":
435 strict = true
436 case "--research", "--auto-research", "--deep":
437 mode = GoalResearchOn
438 case "--simple", "--no-research":
439 mode = GoalResearchOff
440 default:
441 return strict, mode, strings.TrimSpace(rest)
442 }
443 rest = strings.TrimLeftFunc(after, unicode.IsSpace)
444 }
445 return strict, mode, ""
446 }
447
448 func leadingGoalToken(s string) (string, string) {
449 for i, r := range s {
450 if unicode.IsSpace(r) {
451 return s[:i], s[i:]
452 }
453 }
454 return s, ""
455 }
456
457 // CustomCommand resolves a "/name args…" line against the loaded custom slash
458 // commands, returning the rendered prompt to send (found=false when no command
459 // matches). It does not apply the plan-mode marker — call Compose for that.
460 func (c *Controller) CustomCommand(input string) (sent string, found bool) {
461 fields := strings.Fields(input)
462 if len(fields) == 0 {
463 return "", false
464 }
465 name := strings.TrimPrefix(fields[0], "/")
466 for _, cmd := range c.Commands() {
467 if cmd.Name == name {
468 return cmd.Render(fields[1:]), true
469 }
470 }
471 return "", false
472 }
473
474 // resolveSkillInvocation resolves a "/<name> args…" line to its live Skill and
475 // task text. Submit uses RunAs to choose inline main-loop execution or isolated
476 // subagent execution; RunSkill remains the compatibility renderer used by
477 // management/existence checks and callers that explicitly need the body.
478 func (c *Controller) resolveSkillInvocation(input string) (skill.Skill, string, bool) {
479 fields := strings.Fields(input)
480 if len(fields) == 0 {
481 return skill.Skill{}, "", false
482 }
483 name := strings.TrimPrefix(fields[0], "/")
484 sk, ok := c.skills.bySlashName(name)
485 if !ok {
486 return skill.Skill{}, "", false
487 }
488 return sk, strings.Join(fields[1:], " "), true
489 }
490
491 // RunSkill resolves a "/<name> args…" line against the loaded skills and
492 // renders its body. Controller.Submit does not use this renderer for
493 // runAs=subagent skills: direct slash invocation executes those through the
494 // isolated SkillRunner instead.
495 func (c *Controller) RunSkill(input string) (sent string, found bool) {
496 sk, task, ok := c.resolveSkillInvocation(input)
497 if !ok {
498 return "", false
499 }
500 return c.skills.render(sk, task), true
501 }
502
503 // MCPPrompt resolves a "/mcp__server__prompt args…" line: it maps the positional
504 // args onto the prompt's declared arguments and fetches the rendered prompt from
505 // the MCP server (an async prompts/get). found is false when no such prompt
506 // exists; err carries a fetch failure. Honours ctx.
507 func (c *Controller) MCPPrompt(ctx context.Context, input string) (sent string, found bool, err error) {
508 fields := strings.Fields(input)
509 if len(fields) == 0 {
510 return "", false, nil
511 }
512 name := strings.TrimPrefix(fields[0], "/")
513
514 prompts := c.mcp.prompts()
515 idx := -1
516 for i := range prompts {
517 if prompts[i].Name == name {
518 idx = i
519 break
520 }
521 }
522 if idx < 0 {
523 return "", false, nil
524 }
525
526 args := map[string]string{}
527 for i, a := range prompts[idx].Args {
528 if i+1 < len(fields) {
529 args[a.Name] = fields[i+1]
530 }
531 }
532 text, err := prompts[idx].Get(ctx, args)
533 if err != nil {
534 return "", true, err
535 }
536 return text, true, nil
537 }
538
538 lines GO