返回 DeepSeek-Reasonix
input.go
根目录 / internal / control / input.go
1 package control
2
3 import (
4 "context"
5 "fmt"
6 "strconv"
7 "strings"
8 "unicode"
9
10 "reasonix/internal/ablation"
11 "reasonix/internal/agent"
12 "reasonix/internal/memory"
13 "reasonix/internal/planmode"
14 "reasonix/internal/skill"
15 )
16
17 // InvocationRequest is an explicit user-selected Skill or Subagent entity.
18 // Offset is used only to preserve the visual order chosen in the composer.
19 type InvocationRequest struct {
20 Name string `json:"name"`
21 Kind string `json:"kind"`
22 Offset int `json:"offset"`
23 }
24
25 // PlanModeMarker is prepended to every user turn while plan mode is on. It rides
26 // in the user message (not the system prompt or tools), so the cache-stable
27 // prompt prefix is left untouched and the toggle costs nothing in cache hits.
28 const PlanModeMarker = planmode.Marker
29
30 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.]"
31
32 const (
33 activeGoalOpen = "<active-goal>"
34 activeGoalClose = "</active-goal>"
35 hookContextTag = "hook-context"
36 )
37
38 const (
39 maxHookContextChars = 10000
40 maxTotalHookContextChars = 20000
41 )
42
43 const (
44 GoalStatusRunning = "running"
45 GoalStatusComplete = "complete"
46 GoalStatusBlocked = "blocked"
47 GoalStatusStopped = "stopped"
48 )
49
50 type GoalResearchMode int
51
52 const (
53 GoalResearchAuto GoalResearchMode = iota
54 GoalResearchOn
55 GoalResearchOff
56 )
57
58 // StripComposePrefixes removes controller-injected prefixes from a composed
59 // user message so that the display text matches what the user actually typed.
60 // It strips the PlanModeMarker plus transient XML blocks such as
61 // <reasoning-language>, <memory-update>, and <background-jobs> that Compose
62 // prepends to user turns. This is used as a fallback when no .display.json
63 // sidecar recording exists (e.g. sessions created before the display-recording
64 // feature, or synthetic user messages injected by the controller).
65 func StripComposePrefixes(content string) string {
66 s := agent.StripTransientUserBlocks(content)
67 s = stripComposeMarker(s, PlanModeMarker)
68 s = stripComposeMarker(s, legacyPlanModeMarker)
69 s = strings.TrimSpace(s)
70 return s
71 }
72
73 func stripComposeMarker(s, marker string) string {
74 s = strings.TrimPrefix(s, marker+"\n\n")
75 return strings.TrimPrefix(s, marker)
76 }
77
78 // StripReferencedContextPrefix removes the "Referenced context:" preamble and
79 // the trailing XML reference blocks (<file>, <dir>, <resource>, <image>) that
80 // controller.ResolveRefs injects when the user @-references files or resources.
81 // The user's actual input follows the reference blocks after a blank line.
82 // Used for title generation and previews so the displayed text matches what
83 // the user typed, not the injected context preamble (#4954).
84 func StripReferencedContextPrefix(content string) string {
85 const preamble = "Referenced context:"
86 s := strings.TrimSpace(content)
87 if !strings.HasPrefix(s, preamble) {
88 return content
89 }
90 // Skip past the preamble.
91 s = strings.TrimSpace(s[len(preamble):])
92 // Skip past all XML reference blocks: <file ...>...</file>, <dir ...>...</dir>,
93 // <resource ...>...</resource>, <image ...>...</image>.
94 for {
95 s = strings.TrimSpace(s)
96 if s == "" {
97 return ""
98 }
99 // Check for a reference block start.
100 if !strings.HasPrefix(s, "<file ") && !strings.HasPrefix(s, "<dir ") &&
101 !strings.HasPrefix(s, "<resource ") && !strings.HasPrefix(s, "<image ") {
102 break
103 }
104 // Find the matching close tag.
105 tagEnd := strings.IndexByte(s, ' ')
106 if tagEnd < 0 {
107 break
108 }
109 tagName := s[1:tagEnd]
110 closeTag := "</" + tagName + ">"
111 closeIdx := strings.Index(s, closeTag)
112 if closeIdx < 0 {
113 break
114 }
115 s = strings.TrimSpace(s[closeIdx+len(closeTag):])
116 }
117 return s
118 }
119
120 // IsSyntheticUserMessage returns true if the content matches one of the known
121 // synthetic user messages injected by the controller or agent loop (plan
122 // approval, stream recovery, readiness retry, etc.). These should not be shown
123 // in the chat UI.
124 func IsSyntheticUserMessage(content string) bool {
125 if trimmed := strings.TrimSpace(agent.StripTransientUserBlocks(content)); trimmed == planApprovedMessage {
126 return true
127 }
128 // The prefix list lives in internal/agent (agent.SyntheticUserPrefixes) so
129 // preview/title/turn-count derivations there share the exact same filter
130 // (#3653).
131 return agent.IsSyntheticUserText(content)
132 }
133
134 // Compose applies the plan-mode marker to a turn's text when plan mode is on,
135 // returning the message to actually send to the model. The frontend keeps
136 // showing the raw text as the user bubble.
137 func (c *Controller) Compose(text string) string {
138 return c.compose(text, text, true)
139 }
140
141 func (c *Controller) compose(text, source string, includeHookContext bool) string {
142 goal, goalStatus, goalResearchMode, autoResearchTaskID := c.goals.snapshot()
143 return c.composeWithGoal(
144 text,
145 source,
146 includeHookContext,
147 goal,
148 goalStatus,
149 goalResearchMode,
150 autoResearchTaskID,
151 )
152 }
153
154 func (c *Controller) composeWithGoal(
155 text, source string,
156 includeHookContext bool,
157 goal, goalStatus string,
158 goalResearchMode GoalResearchMode,
159 autoResearchTaskID string,
160 ) string {
161 c.mu.Lock()
162 plan := c.planMode
163 responseLanguage := c.responseLanguage
164 reasoningLanguage := c.reasoningLanguage
165 c.mu.Unlock()
166 notes := c.memory.drainPending()
167
168 if strings.TrimSpace(goal) != "" && goalStatus == GoalStatusRunning {
169 prefix := activeGoalBlock(goal, goalResearchMode)
170 if runtime := c.autoResearchRuntimeBlock(autoResearchTaskID); runtime != "" {
171 prefix += "\n\n" + runtime
172 }
173 text = prefix + "\n\n" + text
174 }
175 if plan {
176 text = PlanModeMarker + "\n\n" + text
177 }
178 text = agent.WithResponseLanguage(text, responseLanguage)
179 text = agent.WithReasoningLanguageForSource(text, reasoningLanguage, source)
180
181 // Memory added mid-session rides the turn (never the cached system prefix),
182 // so it takes effect now without invalidating the prompt cache. It folds into
183 // the system prefix on the next session, where it costs nothing per turn.
184 if len(notes) > 0 {
185 var b strings.Builder
186 b.WriteString("<memory-update>\n")
187 b.WriteString("The following project-memory changes were just made and apply from now on:\n")
188 for _, n := range notes {
189 b.WriteString("- " + n + "\n")
190 }
191 b.WriteString("</memory-update>\n\n")
192 text = b.String() + text
193 }
194
195 // Background jobs that finished since the last turn ride the turn too, so the
196 // model learns of completions even though the user-facing notices don't reach
197 // its context. Like memory, this never touches the cache-stable prefix.
198 if c.jobs != nil {
199 if note := c.jobs.DrainCompletedNoteForSession(c.parentSessionID()); note != "" {
200 text = "<background-jobs>\n" + note + "\n</background-jobs>\n\n" + text
201 }
202 }
203 if includeHookContext {
204 if block := c.drainHookContextBlock(); block != "" {
205 text = block + "\n\n" + text
206 }
207 // Relevant facts ride only the real user-turn tail. This preserves the
208 // stable system/tool prefix and keeps synthetic recovery turns free of
209 // accidental recall. A just-written fact already arrives in memory-update.
210 if len(notes) == 0 && !c.ablation.Off(ablation.Retrieval) {
211 if block := c.memory.recall(source).Block(); block != "" {
212 text = strings.TrimRight(text, "\n") + "\n\n" + block
213 }
214 } else if len(notes) > 0 {
215 c.memory.recordRecall(memory.RecallResult{
216 Query: strings.TrimSpace(source),
217 Suppressed: "memory update already supplies the new fact",
218 })
219 }
220 }
221 return text
222 }
223
224 // LastMemoryRecall returns the last real turn's automatic-recall decision for
225 // diagnostics and context-management surfaces.
226 func (c *Controller) LastMemoryRecall() memory.RecallResult {
227 return c.memory.lastRecallResult()
228 }
229
230 func (c *Controller) enqueueHookContexts(contexts []string) {
231 if len(contexts) == 0 {
232 return
233 }
234 c.mu.Lock()
235 defer c.mu.Unlock()
236 for _, context := range contexts {
237 context = strings.TrimSpace(context)
238 if context == "" {
239 continue
240 }
241 c.hookContexts = append(c.hookContexts, context)
242 }
243 }
244
245 func (c *Controller) drainHookContextBlock() string {
246 c.mu.Lock()
247 contexts := c.hookContexts
248 c.hookContexts = nil
249 c.mu.Unlock()
250 if len(contexts) == 0 {
251 return ""
252 }
253 var b strings.Builder
254 b.WriteString(`<hook-context event="SessionStart">`)
255 b.WriteString("\n")
256 total := 0
257 for i, context := range contexts {
258 text, truncated := clipHookContext(context, maxHookContextChars)
259 remaining := maxTotalHookContextChars - total
260 if remaining <= 0 {
261 fmt.Fprintf(&b, "[truncated: omitted %d additional hook context item(s)]\n", len(contexts)-i)
262 break
263 }
264 text, totalTruncated := clipHookContext(text, remaining)
265 total += len([]rune(text))
266 if i > 0 {
267 b.WriteString("\n---\n")
268 }
269 b.WriteString(escapeHookContext(text))
270 b.WriteString("\n")
271 if truncated || totalTruncated {
272 b.WriteString("[truncated]\n")
273 }
274 }
275 b.WriteString(`</hook-context>`)
276 return b.String()
277 }
278
279 func clipHookContext(s string, max int) (string, bool) {
280 r := []rune(s)
281 if len(r) <= max {
282 return s, false
283 }
284 if max < 0 {
285 max = 0
286 }
287 return string(r[:max]), true
288 }
289
290 func escapeHookContext(s string) string {
291 return strings.ReplaceAll(s, "</"+hookContextTag+">", "<\\/"+hookContextTag+">")
292 }
293
294 func (c *Controller) autoResearchRuntimeBlock(taskID string) string {
295 if c.autoResearch == nil || strings.TrimSpace(taskID) == "" {
296 return ""
297 }
298 summary, err := c.autoResearch.Summary(taskID)
299 if err != nil {
300 return "<autoresearch-runtime>\nstatus: invalid\nerror: " + strings.ReplaceAll(err.Error(), autoResearchRuntimeClose, "<\\/autoresearch-runtime>") + "\n</autoresearch-runtime>"
301 }
302 var b strings.Builder
303 b.WriteString("<autoresearch-runtime>\n")
304 b.WriteString("task_id: " + summary.TaskID + "\n")
305 b.WriteString("status: " + summary.Status + "\n")
306 b.WriteString("iteration: ")
307 b.WriteString(strconv.Itoa(summary.Iteration))
308 b.WriteString("\n")
309 b.WriteString("current_direction: " + summary.CurrentDirection + "\n")
310 b.WriteString("stale_count: ")
311 b.WriteString(strconv.Itoa(summary.StaleCount))
312 b.WriteString("\n")
313 b.WriteString("pivot_count: ")
314 b.WriteString(strconv.Itoa(summary.PivotCount))
315 b.WriteString("\n")
316 if summary.PivotRequired {
317 b.WriteString("pivot_required: true\n")
318 } else {
319 b.WriteString("pivot_required: false\n")
320 }
321 b.WriteString("open_success_criteria: ")
322 b.WriteString(strconv.Itoa(len(summary.OpenCriteria)))
323 b.WriteString("\n")
324 for _, criterion := range summary.OpenCriteria {
325 b.WriteString("- ")
326 b.WriteString(criterion.ID)
327 b.WriteString(": ")
328 b.WriteString(strings.ReplaceAll(criterion.Description, "\n", " "))
329 b.WriteString("\n")
330 }
331 if summary.Blocker != "" {
332 b.WriteString("blocker: " + summary.Blocker + "\n")
333 }
334 b.WriteString("next_required_action: " + summary.NextRequiredAction + "\n")
335 b.WriteString("</autoresearch-runtime>")
336 return b.String()
337 }
338
339 const autoResearchRuntimeClose = "</autoresearch-runtime>"
340
341 func reasoningLanguageBlock(lang string) string {
342 return agent.ReasoningLanguageBlock(lang)
343 }
344
345 func (c *Controller) ComposeSynthetic(text string) string {
346 c.mu.Lock()
347 responseLang := c.responseLanguage
348 lang := c.reasoningLanguage
349 c.mu.Unlock()
350 text = agent.WithResponseLanguage(text, responseLang)
351 return agent.WithReasoningLanguageForSource(text, lang, text)
352 }
353
354 func activeGoalBlock(goal string, researchMode GoalResearchMode) string {
355 goal = strings.TrimSpace(goal)
356 goal = strings.ReplaceAll(goal, activeGoalClose, "<\\/active-goal>")
357 var b strings.Builder
358 b.WriteString(activeGoalOpen)
359 b.WriteString("\n")
360 b.WriteString(goal)
361 b.WriteString("\n\n")
362 b.WriteString(goalTaskContractInstructions)
363 if shouldUseAutoResearch(goal, researchMode) {
364 b.WriteString("\n\n")
365 b.WriteString(autoResearchGoalInstructions)
366 }
367 b.WriteString("\n")
368 b.WriteString(activeGoalClose)
369 return b.String()
370 }
371
372 const goalTaskContractInstructions = `Goal mode: pursue this goal autonomously. Treat the user's goal as a task contract:
373 - Honor Context, Request, Output format, Constraints, and Checkpoint/Pause policy sections when present; otherwise infer a lightweight contract from the conversation and workspace.
374 - Preserve scope and output format. Do not invent requirements or hide uncertainty; state assumptions when sensible defaults are enough to proceed.
375 - 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.
376 - Complete only when the concrete request is done, the output format and constraints are satisfied, and relevant verification was attempted or reported unavailable.
377
378 Do not stop after describing a plan; execute the next useful step. End every goal-mode turn by calling the update_goal tool with your disposition: continue (work is ongoing — give the next concrete step in next_action), complete (only when fully done and verified), or blocked (only when the user can unblock). The host validates your claim and decides whether to continue automatically.`
379
380 const autoResearchGoalInstructions = `AutoResearch protocol: this goal looks like long-horizon research, debugging, optimization, or implementation work. Treat AutoResearch as a durable strategy for this Goal, not as a background daemon or a global skill.
381 - Say briefly in the first visible reply that the goal is being handled with AutoResearch and that host-owned state lives under .reasonix/autoresearch/<task-id>/, using the actual task_id from <autoresearch-runtime>.
382 - Keep dynamic state out of REASONIX.md, AGENTS.md, project memory, system prompts, and tool schemas. Use project-local .reasonix/autoresearch/ state only.
383 - Use the task_id and open_success_criteria in <autoresearch-runtime> as authoritative. The host creates task ids and owns state/task_spec.json, state/progress.json, state/findings.jsonl, state/directions_tried.json, state/iteration_log.jsonl, and logs/heartbeat.jsonl.
384 - Do not hand-edit the host-owned AutoResearch state files. When you have direct evidence for an open criterion, include an <autoresearch-evidence> block in your assistant reply so the host can persist it:
385 <autoresearch-evidence>
386 {"criterion_id":"objective_evidence","kind":"file","summary":"What was directly observed","source":"file","paths":["relative/path"],"accepted":true}
387 </autoresearch-evidence>
388 - Before each iteration, use the runtime summary as authoritative, choose a direction that differs materially from directions already tried, execute the smallest evidence-producing chunk, verify it, and report accepted evidence with <autoresearch-evidence> blocks.
389 - Increment stale_count when an iteration lacks accepted evidence or repeats a prior direction. At stale_count >= 2, make a structural pivot such as changing evidence source, entrypoint, implementation boundary, test oracle, benchmark, decomposition, environment, platform, or refutation angle. At stale_count >= 4, stop autonomous digging and ask for the smallest external input needed.
390 - Workers or subagents may gather evidence, but the orchestrator owns canonical state writes. Workers must not publish, push, delete, contact external systems, or write canonical state unless explicitly designated.
391 - Complete only after auditing every open success criterion in <autoresearch-runtime> against direct evidence. Public publishing, destructive changes, credential use, payments, external notifications, privacy-sensitive output, and cache-sensitive changes still require the normal Reasonix gates.`
392
393 func shouldUseAutoResearch(goal string, mode GoalResearchMode) bool {
394 switch mode {
395 case GoalResearchOn:
396 return true
397 case GoalResearchOff:
398 return false
399 }
400 return isAutoResearchGoal(goal)
401 }
402
403 func isAutoResearchGoal(goal string) bool {
404 trimmed := strings.TrimSpace(goal)
405 if trimmed == "" {
406 return false
407 }
408 lower := strings.ToLower(trimmed)
409 if strings.Contains(lower, ".reasonix/autoresearch/") {
410 return true
411 }
412 for _, kw := range autoResearchStrongKeywords {
413 if strings.Contains(lower, kw) {
414 return true
415 }
416 }
417 return autoResearchPhaseCount(lower) >= 4
418 }
419
420 func autoResearchPhaseCount(lower string) int {
421 categories := 0
422 for _, group := range autoResearchPhaseKeywords {
423 if containsAnyGoalKeyword(lower, group) {
424 categories++
425 }
426 }
427 return categories
428 }
429
430 var autoResearchStrongKeywords = []string{
431 "持续",
432 "长期",
433 "彻底",
434 "直到根因",
435 "根因明确",
436 "多轮",
437 "不要原地打转",
438 "别原地打转",
439 "完整方案",
440 "完整做成方案",
441 "跑实验",
442 "反复验证",
443 "长期优化",
444 "系统性研究",
445 "持续研究",
446 "持续排查",
447 "持续推进",
448 "长期跑",
449 "long-horizon",
450 "long horizon",
451 "long-running",
452 "keep researching",
453 "keep working",
454 "root cause",
455 "until the root cause",
456 "do not spin",
457 "don't spin",
458 "thoroughly",
459 "systematically",
460 }
461
462 var autoResearchPhaseKeywords = [][]string{
463 {"研究", "调研", "排查", "分析", "定位", "诊断", "research", "investigate", "diagnose", "analyze", "analysis"},
464 {"实现", "修复", "改造", "开发", "重构", "implement", "build", "fix", "refactor"},
465 {"验证", "测试", "复现", "联调", "benchmark", "verify", "validate", "test", "reproduce"},
466 {"优化", "完善", "提升", "收敛", "optimize", "improve", "tune", "polish"},
467 {"文档", "方案", "说明", "总结", "document", "docs", "writeup", "plan"},
468 {"发布", "上线", "提交", "pull request", "publish", "ship", "deploy"},
469 }
470
471 func containsAnyGoalKeyword(s string, needles []string) bool {
472 for _, needle := range needles {
473 if strings.Contains(s, needle) {
474 return true
475 }
476 }
477 return false
478 }
479
480 // MemoryQuickAddNote parses the "# <note>" memory shortcut. The space after
481 // "#" is intentional: "#7", "#issue", and "#标题" are ordinary user prompts,
482 // not memory writes. Multi-line input starting with "# " is NOT treated as a
483 // quick-add note — it is almost certainly a Markdown heading in a structured
484 // prompt (e.g. "# Context\n\n- file.go\n# Objective"). Only single-line input
485 // may be a quick-add note.
486 func MemoryQuickAddNote(input string) (note string, ok bool) {
487 trimmed := strings.TrimSpace(input)
488 if strings.Contains(trimmed, "\n") {
489 return "", false
490 }
491 if strings.HasPrefix(trimmed, "# ") || strings.HasPrefix(trimmed, "#\t") {
492 return strings.TrimSpace(trimmed[1:]), true
493 }
494 return "", false
495 }
496
497 // RememberCommandNote parses the explicit "/remember <note>" memory command.
498 func RememberCommandNote(input string) (note string, ok bool) {
499 trimmed := strings.TrimSpace(input)
500 switch {
501 case trimmed == "/remember":
502 return "", true
503 case strings.HasPrefix(trimmed, "/remember ") || strings.HasPrefix(trimmed, "/remember\t"):
504 return strings.TrimSpace(trimmed[len("/remember"):]), true
505 default:
506 return "", false
507 }
508 }
509
510 type GoalCommandAction int
511
512 const (
513 GoalCommandStatus GoalCommandAction = iota + 1
514 GoalCommandSet
515 GoalCommandClear
516 GoalCommandPause
517 GoalCommandResume
518 )
519
520 type GoalCommand struct {
521 Action GoalCommandAction
522 Text string
523 Strict bool
524 ResearchMode GoalResearchMode
525 }
526
527 func ParseGoalCommand(input string) (GoalCommand, bool) {
528 trimmed := strings.TrimSpace(input)
529 if trimmed != "/goal" && !strings.HasPrefix(trimmed, "/goal ") && !strings.HasPrefix(trimmed, "/goal\t") {
530 return GoalCommand{}, false
531 }
532 args := strings.TrimSpace(trimmed[len("/goal"):])
533 strict, researchMode, actionArgs := parseLeadingGoalFlags(args)
534
535 switch strings.ToLower(actionArgs) {
536 case "", "status":
537 return GoalCommand{Action: GoalCommandStatus, Strict: strict, ResearchMode: researchMode}, true
538 case "clear", "off", "stop", "done":
539 return GoalCommand{Action: GoalCommandClear, Strict: strict, ResearchMode: researchMode}, true
540 case "pause":
541 return GoalCommand{Action: GoalCommandPause, Strict: strict, ResearchMode: researchMode}, true
542 case "resume":
543 return GoalCommand{Action: GoalCommandResume, Strict: strict, ResearchMode: researchMode}, true
544 default:
545 return GoalCommand{Action: GoalCommandSet, Text: actionArgs, Strict: strict, ResearchMode: researchMode}, true
546 }
547 }
548
549 func parseLeadingGoalFlags(args string) (bool, GoalResearchMode, string) {
550 strict := false
551 mode := GoalResearchAuto
552 rest := strings.TrimLeftFunc(args, unicode.IsSpace)
553 for rest != "" {
554 token, after := leadingGoalToken(rest)
555 switch strings.ToLower(token) {
556 case "--strict":
557 strict = true
558 case "--research", "--auto-research", "--deep":
559 mode = GoalResearchOn
560 case "--simple", "--no-research":
561 mode = GoalResearchOff
562 default:
563 return strict, mode, strings.TrimSpace(rest)
564 }
565 rest = strings.TrimLeftFunc(after, unicode.IsSpace)
566 }
567 return strict, mode, ""
568 }
569
570 func leadingGoalToken(s string) (string, string) {
571 for i, r := range s {
572 if unicode.IsSpace(r) {
573 return s[:i], s[i:]
574 }
575 }
576 return s, ""
577 }
578
579 // CustomCommand resolves a "/name args…" line against the loaded custom slash
580 // commands, returning the rendered prompt to send (found=false when no command
581 // matches). It does not apply the plan-mode marker — call Compose for that.
582 func (c *Controller) CustomCommand(input string) (sent string, found bool) {
583 fields := strings.Fields(input)
584 if len(fields) == 0 {
585 return "", false
586 }
587 name := strings.TrimPrefix(fields[0], "/")
588 for _, cmd := range c.Commands() {
589 if cmd.Name == name {
590 return cmd.Render(fields[1:]), true
591 }
592 }
593 return "", false
594 }
595
596 // resolveSkillInvocation resolves a "/<name> args…" line to its live Skill and
597 // task text. Submit uses RunAs to choose inline main-loop execution or isolated
598 // subagent execution; RunSkill remains the compatibility renderer used by
599 // management/existence checks and callers that explicitly need the body.
600 func (c *Controller) resolveSkillInvocation(input string) (skill.Skill, string, bool) {
601 fields := strings.Fields(input)
602 if len(fields) == 0 {
603 return skill.Skill{}, "", false
604 }
605 name := strings.TrimPrefix(fields[0], "/")
606 sk, ok := c.skills.bySlashName(name)
607 if !ok {
608 return skill.Skill{}, "", false
609 }
610 return sk, strings.Join(fields[1:], " "), true
611 }
612
613 // RunSkill resolves a "/<name> args…" line against the loaded skills and
614 // renders its body. Controller.Submit does not use this renderer for
615 // runAs=subagent skills: direct slash invocation executes those through the
616 // isolated SkillRunner instead.
617 func (c *Controller) RunSkill(input string) (sent string, found bool) {
618 sk, task, ok := c.resolveSkillInvocation(input)
619 if !ok {
620 return "", false
621 }
622 return c.skills.render(sk, task), true
623 }
624
625 // MCPPrompt resolves a "/mcp__server__prompt args…" line: it maps the positional
626 // args onto the prompt's declared arguments and fetches the rendered prompt from
627 // the MCP server (an async prompts/get). found is false when no such prompt
628 // exists; err carries a fetch failure. Honours ctx.
629 func (c *Controller) MCPPrompt(ctx context.Context, input string) (sent string, found bool, err error) {
630 fields := strings.Fields(input)
631 if len(fields) == 0 {
632 return "", false, nil
633 }
634 name := strings.TrimPrefix(fields[0], "/")
635
636 prompts := c.mcp.prompts()
637 idx := -1
638 for i := range prompts {
639 if prompts[i].Name == name {
640 idx = i
641 break
642 }
643 }
644 if idx < 0 {
645 return "", false, nil
646 }
647
648 args := map[string]string{}
649 for i, a := range prompts[idx].Args {
650 if i+1 < len(fields) {
651 args[a.Name] = fields[i+1]
652 }
653 }
654 text, err := prompts[idx].Get(ctx, args)
655 if err != nil {
656 return "", true, err
657 }
658 return text, true, nil
659 }
660
660 lines GO