返回 DeepSeek-Reasonix
index.go
根目录 / internal / skill / index.go
1 package skill
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/textutil"
8 )
9
10 // IndexMaxChars caps the pinned skills-index block so it can't bloat the
11 // cache-stable system-prompt prefix; bodies never enter the prefix.
12 const IndexMaxChars = 4000
13
14 const missingDescPlaceholder = `(no description — frontmatter is missing a "description:" line; tell the user to add one)`
15
16 // indexHeader introduces the skills block in the system prompt: the invocation
17 // policy (mandatory for inline, judgment-based for subagent) and how to call one.
18 const indexHeader = "# Skills — playbooks you can invoke\n\n" +
19 "One-liner index. Before non-trivial work, scan it: if an untagged (inline) skill is even plausibly relevant to the task, invoke it before continuing instead of pre-judging — loading one imperfect inline skill is cheap. Skills tagged `[🧬 subagent]` are the heavy path; reach for them only when the task genuinely needs context-heavy work, not on weak relevance. Each entry is a built-in or a user-authored playbook. Call `run_skill({ name: \"<skill-name>\", arguments: \"<task>\" })` — `name` is JUST the identifier (e.g. `\"explore\"`), NOT the `[🧬 subagent]` tag that follows it. Prefer the dedicated top-level tool when one exists for a built-in subagent skill. Entries tagged `[🧬 subagent]` spawn an isolated subagent — its tool calls and reasoning never enter your context, only its final answer does; use them for context-heavy work (deep exploration, multi-step research) where you only need the conclusion. Untagged skills are inlined: the body becomes a tool result you read and act on directly. The user can also invoke a skill via `/<name>`."
20
21 const readOnlyIndexHeader = "# Skills — read-only playbooks you can invoke\n\n" +
22 "One-liner index for the narrow read-only skill surface. Call `read_only_skill({ name: \"<skill-name>\", arguments: \"<task>\" })` — `name` is JUST the identifier, NOT the `[🧬 subagent]` tag. Inline skills are loaded into context. Skills tagged `[🧬 subagent]` run in an isolated ephemeral read-only subagent with only read-only research tools and safe foreground bash; no writes, installers, memory mutation, continuation/fork, background jobs, or writer-capable delegation are available. Read-only nested delegation may be available until max_subagent_depth is reached."
23
24 // IndexBlock renders the system/tool-result skills listing without attaching it
25 // to a base prompt. Only names + descriptions (+ a subagent tag) are listed;
26 // bodies load on demand via run_skill.
27 func IndexBlock(skills []Skill) string {
28 return indexBlockWithHeader(indexHeader, skills)
29 }
30
31 // ReadOnlyIndexBlock renders the same listing with read_only_skill-specific
32 // invocation guidance for token-economy plan-mode connections.
33 func ReadOnlyIndexBlock(skills []Skill) string {
34 return indexBlockWithHeader(readOnlyIndexHeader, skills)
35 }
36
37 func indexBlockWithHeader(header string, skills []Skill) string {
38 if len(skills) == 0 {
39 return ""
40 }
41 lines := make([]string, 0, len(skills))
42 for _, sk := range skills {
43 // Manual-invocation skills (e.g. user-authored subagent profiles) stay
44 // invocable by name (/<name>, run_skill) but must never enter the
45 // pinned index the model scans for candidates to call on its own
46 // initiative.
47 if sk.Invocation == "manual" {
48 continue
49 }
50 lines = append(lines, indexLine(sk))
51 }
52 if len(lines) == 0 {
53 return ""
54 }
55 joined := strings.Join(lines, "\n")
56 if r := []rune(joined); len(r) > IndexMaxChars {
57 joined = string(r[:IndexMaxChars]) + fmt.Sprintf("\n… (truncated %d chars)", len(r)-IndexMaxChars)
58 }
59 return header + "\n\n```\n" + joined + "\n```"
60 }
61
62 // ApplyIndex appends the skills index to basePrompt, or returns it unchanged
63 // when there are no skills. Only names + descriptions (+ a subagent tag) are
64 // listed; bodies load on demand via run_skill.
65 func ApplyIndex(basePrompt string, skills []Skill) string {
66 block := IndexBlock(skills)
67 if block == "" {
68 return basePrompt
69 }
70 return basePrompt + "\n\n" + block
71 }
72
73 // indexLine renders one skill as "- name [tag] — description", clipped to a
74 // stable width. The subagent tag goes after the name so a model copying the line
75 // into run_skill's `name` arg still yields a clean identifier.
76 func indexLine(sk Skill) string {
77 desc := strings.TrimSpace(strings.ReplaceAll(sk.Description, "\n", " "))
78 if desc == "" {
79 desc = missingDescPlaceholder
80 }
81 tag := ""
82 if sk.RunAs == RunSubagent {
83 tag = " [🧬 subagent]"
84 }
85 max := 130 - len([]rune(sk.Name)) - len([]rune(tag))
86 clipped := clipRunes(desc, max)
87 if clipped == "" {
88 return "- " + sk.Name + tag
89 }
90 return "- " + sk.Name + tag + " — " + clipped
91 }
92
93 // clipRunes preserves the historical name but clips by grapheme clusters so
94 // combined emoji and other user-visible characters stay intact.
95 func clipRunes(s string, max int) string {
96 return textutil.ClipGraphemes(s, max, "…")
97 }
98
98 lines GO