detached 9ec8550

Merge branch 'main' into octo/20260729-model-add-recvqd13uNrAsi

commit
9ec8550dbac49c32af32d3c5e076c169ff240cb2
author
Patric Eckhart · · 2026-07-29 16:25 UTC
committer
GitHub · · 2026-07-29 16:25 UTC
merge of
bd3e66a + 079dd65 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +324 -19
M docs/skills.md +23 -12
@@ -1,8 +1,9 @@
11 # zot skills
22
33 A skill is a reusable instruction set written as a single
4-`SKILL.md` file with a YAML frontmatter header. zot discovers skills
5-at startup and surfaces them to the model in two ways:
4+`SKILL.md` file with a YAML frontmatter header. Unless a skill disables
5+model invocation, zot discovers it at startup and surfaces it to the model
6+in two ways:
67
78 1. The system prompt gains a short manifest:
89 `Available skills: ... - code-review — Run a self-review pass...`
@@ -35,6 +36,7 @@ When asked to review code, ...
3536 |---|---|---|
3637 | `name` | optional | skill identifier; defaults to the directory name |
3738 | `description` | required | one-line summary shown in the system prompt |
39+| `disable-model-invocation` | optional | when `true`, hide the skill from the model's startup manifest; invoke it explicitly with `/skill:<name>` |
3840 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
3941 | `permissions` | optional | per-tool patterns; informational |
4042
@@ -74,18 +76,27 @@ In zot, run `/skills`. A picker lists every discovered skill with its
7476 description and source path. Press enter on a row to view the full
7577 body inline. Press esc to go back.
7678
77-## How the model uses a skill
79+## Invoking skills
7880
79-1. The system prompt tells the model that skills exist and what
80- their names + descriptions are.
81-2. The model recognises a request that maps to a known skill and
82- calls the `skill` tool with `name: "<skill-name>"`.
83-3. The `skill` tool returns the markdown body as the tool result.
84-4. The model follows the body's instructions.
81+For normal skills, the system prompt tells the model the skill names and
82+short descriptions. When a request matches, the model calls the `skill` tool
83+to load the full instructions on demand.
8584
86-You can prompt the model directly to use a skill (e.g. "use the
87-code-review skill") but you don't have to — the descriptions in the
88-manifest are enough for it to choose on its own.
85+To force a specific skill, invoke it as a slash command:
86+
87+```text
88+/skill:code-review
89+/skill:code-review focus on security issues
90+```
91+
92+zot expands the command into a user message containing the complete skill
93+body, its directory for resolving relative references, and any text following
94+the command as the request. This bypasses model-side skill selection.
95+
96+Set `disable-model-invocation: true` in a skill's frontmatter when it should
97+only run after explicit user invocation. The skill remains visible in
98+`/skills` and available through `/skill:<name>`, but its name and description
99+are omitted from the model's startup context.
89100
90101 ## Writing good skills
91102
@@ -1,8 +1,9 @@
1 # zot skills1 # zot skills
2 2
3 A skill is a reusable instruction set written as a single3 A skill is a reusable instruction set written as a single
4-`SKILL.md` file with a YAML frontmatter header. zot discovers skills4+`SKILL.md` file with a YAML frontmatter header. Unless a skill disables
5-at startup and surfaces them to the model in two ways:5+model invocation, zot discovers it at startup and surfaces it to the model
6+in two ways:
6 7
7 1. The system prompt gains a short manifest:8 1. The system prompt gains a short manifest:
8 `Available skills: ... - code-review — Run a self-review pass...`9 `Available skills: ... - code-review — Run a self-review pass...`
@@ -35,6 +36,7 @@ When asked to review code, ...
35 |---|---|---|36 |---|---|---|
36 | `name` | optional | skill identifier; defaults to the directory name |37 | `name` | optional | skill identifier; defaults to the directory name |
37 | `description` | required | one-line summary shown in the system prompt |38 | `description` | required | one-line summary shown in the system prompt |
39+| `disable-model-invocation` | optional | when `true`, hide the skill from the model's startup manifest; invoke it explicitly with `/skill:<name>` |
38 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |40 | `allowed-tools` | optional | list of tool names the skill is meant to use; informational |
39 | `permissions` | optional | per-tool patterns; informational |41 | `permissions` | optional | per-tool patterns; informational |
40 42
@@ -74,18 +76,27 @@ In zot, run `/skills`. A picker lists every discovered skill with its
74 description and source path. Press enter on a row to view the full76 description and source path. Press enter on a row to view the full
75 body inline. Press esc to go back.77 body inline. Press esc to go back.
76 78
77-## How the model uses a skill79+## Invoking skills
78 80
79-1. The system prompt tells the model that skills exist and what81+For normal skills, the system prompt tells the model the skill names and
80- their names + descriptions are.82+short descriptions. When a request matches, the model calls the `skill` tool
81-2. The model recognises a request that maps to a known skill and83+to load the full instructions on demand.
82- calls the `skill` tool with `name: "<skill-name>"`.
83-3. The `skill` tool returns the markdown body as the tool result.
84-4. The model follows the body's instructions.
85 84
86-You can prompt the model directly to use a skill (e.g. "use the85+To force a specific skill, invoke it as a slash command:
87-code-review skill") but you don't have to — the descriptions in the86+
88-manifest are enough for it to choose on its own.87+```text
88+/skill:code-review
89+/skill:code-review focus on security issues
90+```
91+
92+zot expands the command into a user message containing the complete skill
93+body, its directory for resolving relative references, and any text following
94+the command as the request. This bypasses model-side skill selection.
95+
96+Set `disable-model-invocation: true` in a skill's frontmatter when it should
97+only run after explicit user invocation. The skill remains visible in
98+`/skills` and available through `/skill:<name>`, but its name and description
99+are omitted from the model's startup context.
89 100
90 ## Writing good skills101 ## Writing good skills
91 102
M packages/agent/modes/interactive.go +51 -0
@@ -4161,6 +4161,27 @@ func longestCommonPrefix(ss []string) string {
41614161
41624162 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
41634163 parts := strings.Fields(cmd)
4164+ if len(parts) == 0 {
4165+ return false
4166+ }
4167+
4168+ if isSkillCommand(cmd) {
4169+ var available []*skills.Skill
4170+ if i.cfg.SkillSnapshot != nil {
4171+ available = i.cfg.SkillSnapshot()
4172+ }
4173+ prompt, _, err := expandSkillCommand(cmd, available)
4174+ if err != nil {
4175+ i.mu.Lock()
4176+ i.statusErr = err.Error()
4177+ i.statusOK = ""
4178+ i.mu.Unlock()
4179+ return false
4180+ }
4181+ i.submitOrQueuePrompt(ctx, prompt)
4182+ return false
4183+ }
4184+
41644185 switch strings.ToLower(parts[0]) {
41654186 case "/exit":
41664187 return true
@@ -4787,6 +4808,36 @@ func (i *Interactive) openBtwDialog(args []string) {
47874808 i.invalidate()
47884809 }
47894810
4811+// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
4812+// or queues it behind the active turn using the normal text-only queue.
4813+func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
4814+ i.mu.Lock()
4815+ if i.agent == nil {
4816+ i.statusErr = "not logged in. type /login first."
4817+ i.statusOK = ""
4818+ i.mu.Unlock()
4819+ i.invalidate()
4820+ return
4821+ }
4822+ busy := i.busy
4823+ compacting := i.compacting
4824+ ag := i.agent
4825+ i.mu.Unlock()
4826+
4827+ if busy {
4828+ if !compacting {
4829+ ag.QueueMessage(prompt)
4830+ } else {
4831+ i.mu.Lock()
4832+ i.queued = append(i.queued, prompt)
4833+ i.mu.Unlock()
4834+ }
4835+ i.invalidate()
4836+ return
4837+ }
4838+ i.startTurn(ctx, prompt)
4839+}
4840+
47904841 // openSkillsDialog opens the skill inspector. The picker reflects
47914842 // whatever SkillSnapshot returns at call time, so edits to a
47924843 // SKILL.md made during a session show up on the next /skills.
@@ -4161,6 +4161,27 @@ func longestCommonPrefix(ss []string) string {
4161 4161
4162 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {4162 func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
4163 parts := strings.Fields(cmd)4163 parts := strings.Fields(cmd)
4164+ if len(parts) == 0 {
4165+ return false
4166+ }
4167+
4168+ if isSkillCommand(cmd) {
4169+ var available []*skills.Skill
4170+ if i.cfg.SkillSnapshot != nil {
4171+ available = i.cfg.SkillSnapshot()
4172+ }
4173+ prompt, _, err := expandSkillCommand(cmd, available)
4174+ if err != nil {
4175+ i.mu.Lock()
4176+ i.statusErr = err.Error()
4177+ i.statusOK = ""
4178+ i.mu.Unlock()
4179+ return false
4180+ }
4181+ i.submitOrQueuePrompt(ctx, prompt)
4182+ return false
4183+ }
4184+
4164 switch strings.ToLower(parts[0]) {4185 switch strings.ToLower(parts[0]) {
4165 case "/exit":4186 case "/exit":
4166 return true4187 return true
@@ -4787,6 +4808,36 @@ func (i *Interactive) openBtwDialog(args []string) {
4787 i.invalidate()4808 i.invalidate()
4788 }4809 }
4789 4810
4811+// submitOrQueuePrompt submits a slash command's expanded prompt immediately,
4812+// or queues it behind the active turn using the normal text-only queue.
4813+func (i *Interactive) submitOrQueuePrompt(ctx context.Context, prompt string) {
4814+ i.mu.Lock()
4815+ if i.agent == nil {
4816+ i.statusErr = "not logged in. type /login first."
4817+ i.statusOK = ""
4818+ i.mu.Unlock()
4819+ i.invalidate()
4820+ return
4821+ }
4822+ busy := i.busy
4823+ compacting := i.compacting
4824+ ag := i.agent
4825+ i.mu.Unlock()
4826+
4827+ if busy {
4828+ if !compacting {
4829+ ag.QueueMessage(prompt)
4830+ } else {
4831+ i.mu.Lock()
4832+ i.queued = append(i.queued, prompt)
4833+ i.mu.Unlock()
4834+ }
4835+ i.invalidate()
4836+ return
4837+ }
4838+ i.startTurn(ctx, prompt)
4839+}
4840+
4790 // openSkillsDialog opens the skill inspector. The picker reflects4841 // openSkillsDialog opens the skill inspector. The picker reflects
4791 // whatever SkillSnapshot returns at call time, so edits to a4842 // whatever SkillSnapshot returns at call time, so edits to a
4792 // SKILL.md made during a session show up on the next /skills.4843 // SKILL.md made during a session show up on the next /skills.
A packages/agent/modes/skill_command.go +42 -0
@@ -0,0 +1,42 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+)
9+
10+const skillCommandPrefix = "/skill:"
11+
12+func isSkillCommand(text string) bool {
13+ text = strings.TrimSpace(text)
14+ return len(text) >= len(skillCommandPrefix) && strings.EqualFold(text[:len(skillCommandPrefix)], skillCommandPrefix)
15+}
16+
17+// expandSkillCommand expands /skill:<name> [request] into a prompt containing
18+// the complete skill instructions. The boolean reports whether text uses the
19+// skill-command syntax.
20+func expandSkillCommand(text string, available []*skills.Skill) (prompt string, recognized bool, err error) {
21+ text = strings.TrimSpace(text)
22+ if !isSkillCommand(text) {
23+ return "", false, nil
24+ }
25+
26+ head := text
27+ args := ""
28+ if idx := strings.IndexAny(text, " \t\n"); idx >= 0 {
29+ head = text[:idx]
30+ args = strings.TrimSpace(text[idx:])
31+ }
32+ name := head[len(skillCommandPrefix):]
33+ if name == "" {
34+ return "", true, fmt.Errorf("usage: /skill:<name> [request]")
35+ }
36+
37+ skill := skills.FindByName(available, name)
38+ if skill == nil {
39+ return "", true, fmt.Errorf("unknown skill %q; run /skills to see what is available", name)
40+ }
41+ return skills.InvocationPrompt(skill, args), true, nil
42+}
@@ -0,0 +1,42 @@
1+package modes
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+)
9+
10+const skillCommandPrefix = "/skill:"
11+
12+func isSkillCommand(text string) bool {
13+ text = strings.TrimSpace(text)
14+ return len(text) >= len(skillCommandPrefix) && strings.EqualFold(text[:len(skillCommandPrefix)], skillCommandPrefix)
15+}
16+
17+// expandSkillCommand expands /skill:<name> [request] into a prompt containing
18+// the complete skill instructions. The boolean reports whether text uses the
19+// skill-command syntax.
20+func expandSkillCommand(text string, available []*skills.Skill) (prompt string, recognized bool, err error) {
21+ text = strings.TrimSpace(text)
22+ if !isSkillCommand(text) {
23+ return "", false, nil
24+ }
25+
26+ head := text
27+ args := ""
28+ if idx := strings.IndexAny(text, " \t\n"); idx >= 0 {
29+ head = text[:idx]
30+ args = strings.TrimSpace(text[idx:])
31+ }
32+ name := head[len(skillCommandPrefix):]
33+ if name == "" {
34+ return "", true, fmt.Errorf("usage: /skill:<name> [request]")
35+ }
36+
37+ skill := skills.FindByName(available, name)
38+ if skill == nil {
39+ return "", true, fmt.Errorf("unknown skill %q; run /skills to see what is available", name)
40+ }
41+ return skills.InvocationPrompt(skill, args), true, nil
42+}
A packages/agent/modes/skill_command_test.go +49 -0
@@ -0,0 +1,49 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+)
9+
10+func TestExpandSkillCommandForcesNamedSkill(t *testing.T) {
11+ available := []*skills.Skill{{
12+ Name: "review",
13+ Body: "Inspect the complete diff.",
14+ }}
15+
16+ got, recognized, err := expandSkillCommand("/skill:review focus on security", available)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if !recognized {
21+ t.Fatal("skill command was not recognized")
22+ }
23+ for _, want := range []string{"# Skill: review", "Inspect the complete diff.", "User request:\nfocus on security"} {
24+ if !strings.Contains(got, want) {
25+ t.Errorf("expanded prompt missing %q:\n%s", want, got)
26+ }
27+ }
28+}
29+
30+func TestExpandSkillCommandRejectsUnknownSkill(t *testing.T) {
31+ _, recognized, err := expandSkillCommand("/skill:missing", nil)
32+ if !recognized {
33+ t.Fatal("skill command was not recognized")
34+ }
35+ if err == nil || !strings.Contains(err.Error(), "unknown skill") {
36+ t.Fatalf("error = %v, want unknown skill", err)
37+ }
38+}
39+
40+func TestSkillCommandRoutesAsSlashCommand(t *testing.T) {
41+ for _, text := range []string{"/skill:review", "/skill:review focus here", "/SKILL:review"} {
42+ if !looksLikeSlashCommand(text) {
43+ t.Errorf("looksLikeSlashCommand(%q) = false", text)
44+ }
45+ if !isKnownSlashCommand(text) {
46+ t.Errorf("isKnownSlashCommand(%q) = false", text)
47+ }
48+ }
49+}
@@ -0,0 +1,49 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+)
9+
10+func TestExpandSkillCommandForcesNamedSkill(t *testing.T) {
11+ available := []*skills.Skill{{
12+ Name: "review",
13+ Body: "Inspect the complete diff.",
14+ }}
15+
16+ got, recognized, err := expandSkillCommand("/skill:review focus on security", available)
17+ if err != nil {
18+ t.Fatal(err)
19+ }
20+ if !recognized {
21+ t.Fatal("skill command was not recognized")
22+ }
23+ for _, want := range []string{"# Skill: review", "Inspect the complete diff.", "User request:\nfocus on security"} {
24+ if !strings.Contains(got, want) {
25+ t.Errorf("expanded prompt missing %q:\n%s", want, got)
26+ }
27+ }
28+}
29+
30+func TestExpandSkillCommandRejectsUnknownSkill(t *testing.T) {
31+ _, recognized, err := expandSkillCommand("/skill:missing", nil)
32+ if !recognized {
33+ t.Fatal("skill command was not recognized")
34+ }
35+ if err == nil || !strings.Contains(err.Error(), "unknown skill") {
36+ t.Fatalf("error = %v, want unknown skill", err)
37+ }
38+}
39+
40+func TestSkillCommandRoutesAsSlashCommand(t *testing.T) {
41+ for _, text := range []string{"/skill:review", "/skill:review focus here", "/SKILL:review"} {
42+ if !looksLikeSlashCommand(text) {
43+ t.Errorf("looksLikeSlashCommand(%q) = false", text)
44+ }
45+ if !isKnownSlashCommand(text) {
46+ t.Errorf("isKnownSlashCommand(%q) = false", text)
47+ }
48+ }
49+}
M packages/agent/modes/skills_dialog.go +6 -1
@@ -122,6 +122,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
122122 if end < len(d.skills) {
123123 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124124 }
125+ out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
125126 out = append(out, frameRule(th, width))
126127 return out
127128 }
@@ -166,7 +167,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
166167
167168 func formatSkillRow(s *skills.Skill, maxWidth int) string {
168169 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
169- src := " " + truncateLineSafe(s.Source, 16)
170+ source := s.Source
171+ if s.DisableModelInvocation {
172+ source = "manual, " + source
173+ }
174+ src := " " + truncateLineSafe(source, 16)
170175 room := maxWidth - len(left) - len(src)
171176 if room < 10 {
172177 room = 10
@@ -122,6 +122,7 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
122 if end < len(d.skills) {122 if end < len(d.skills) {
123 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))123 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
124 }124 }
125+ out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
125 out = append(out, frameRule(th, width))126 out = append(out, frameRule(th, width))
126 return out127 return out
127 }128 }
@@ -166,7 +167,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
166 167
167 func formatSkillRow(s *skills.Skill, maxWidth int) string {168 func formatSkillRow(s *skills.Skill, maxWidth int) string {
168 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))169 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
169- src := " " + truncateLineSafe(s.Source, 16)170+ source := s.Source
171+ if s.DisableModelInvocation {
172+ source = "manual, " + source
173+ }
174+ src := " " + truncateLineSafe(source, 16)
170 room := maxWidth - len(left) - len(src)175 room := maxWidth - len(left) - len(src)
171 if room < 10 {176 if room < 10 {
172 room = 10177 room = 10
M packages/agent/modes/slash_suggest.go +11 -2
@@ -158,8 +158,8 @@ func (s *slashSuggester) baseCatalog() []slashCommand {
158158 // ("/foo.bar/") so those can be sent to the model as-is.
159159 //
160160 // The head after "/" must be a single simple word: only letters,
161-// digits, hyphens, and underscores. That excludes paths (contain "/"),
162-// regexes (contain "."), and URLs.
161+// digits, hyphens, and underscores. The /skill:<name> form additionally
162+// permits one colon. Paths, regexes, and URLs are still excluded.
163163 func looksLikeSlashCommand(text string) bool {
164164 text = strings.TrimSpace(text)
165165 if len(text) < 2 || text[0] != '/' {
@@ -172,6 +172,12 @@ func looksLikeSlashCommand(text string) bool {
172172 if head == "" {
173173 return false
174174 }
175+ if strings.HasPrefix(strings.ToLower(head), "skill:") {
176+ head = head[len("skill:"):]
177+ if head == "" {
178+ return true
179+ }
180+ }
175181 for _, r := range head {
176182 if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') &&
177183 !(r >= '0' && r <= '9') && r != '-' && r != '_' {
@@ -194,6 +200,9 @@ func isKnownSlashCommand(text string) bool {
194200 if i := strings.IndexAny(text, " \t\n"); i >= 0 {
195201 head = text[:i]
196202 }
203+ if len(head) >= len(skillCommandPrefix) && strings.EqualFold(head[:len(skillCommandPrefix)], skillCommandPrefix) {
204+ return true
205+ }
197206 for _, c := range slashCatalog {
198207 if strings.EqualFold(c.Name, head) {
199208 return true
@@ -158,8 +158,8 @@ func (s *slashSuggester) baseCatalog() []slashCommand {
158 // ("/foo.bar/") so those can be sent to the model as-is.158 // ("/foo.bar/") so those can be sent to the model as-is.
159 //159 //
160 // The head after "/" must be a single simple word: only letters,160 // The head after "/" must be a single simple word: only letters,
161-// digits, hyphens, and underscores. That excludes paths (contain "/"),161+// digits, hyphens, and underscores. The /skill:<name> form additionally
162-// regexes (contain "."), and URLs.162+// permits one colon. Paths, regexes, and URLs are still excluded.
163 func looksLikeSlashCommand(text string) bool {163 func looksLikeSlashCommand(text string) bool {
164 text = strings.TrimSpace(text)164 text = strings.TrimSpace(text)
165 if len(text) < 2 || text[0] != '/' {165 if len(text) < 2 || text[0] != '/' {
@@ -172,6 +172,12 @@ func looksLikeSlashCommand(text string) bool {
172 if head == "" {172 if head == "" {
173 return false173 return false
174 }174 }
175+ if strings.HasPrefix(strings.ToLower(head), "skill:") {
176+ head = head[len("skill:"):]
177+ if head == "" {
178+ return true
179+ }
180+ }
175 for _, r := range head {181 for _, r := range head {
176 if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') &&182 if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') &&
177 !(r >= '0' && r <= '9') && r != '-' && r != '_' {183 !(r >= '0' && r <= '9') && r != '-' && r != '_' {
@@ -194,6 +200,9 @@ func isKnownSlashCommand(text string) bool {
194 if i := strings.IndexAny(text, " \t\n"); i >= 0 {200 if i := strings.IndexAny(text, " \t\n"); i >= 0 {
195 head = text[:i]201 head = text[:i]
196 }202 }
203+ if len(head) >= len(skillCommandPrefix) && strings.EqualFold(head[:len(skillCommandPrefix)], skillCommandPrefix) {
204+ return true
205+ }
197 for _, c := range slashCatalog {206 for _, c := range slashCatalog {
198 if strings.EqualFold(c.Name, head) {207 if strings.EqualFold(c.Name, head) {
199 return true208 return true
A packages/agent/skills/invocation.go +33 -0
@@ -0,0 +1,33 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// InvocationPrompt expands a user-invoked skill into a prompt that includes
10+// its complete instructions. Arguments are appended as the request to perform.
11+func InvocationPrompt(skill *Skill, args string) string {
12+ if skill == nil {
13+ return ""
14+ }
15+
16+ var sb strings.Builder
17+ sb.WriteString("Use the following skill for this request. Follow its instructions.\n\n")
18+ fmt.Fprintf(&sb, "# Skill: %s\n\n", skill.Name)
19+ if desc := strings.TrimSpace(skill.Description); desc != "" {
20+ sb.WriteString(desc)
21+ sb.WriteString("\n\n")
22+ }
23+ if skill.Path != "" {
24+ fmt.Fprintf(&sb, "Skill directory: %s\n\n", filepath.Dir(skill.Path))
25+ }
26+ sb.WriteString("---\n\n")
27+ sb.WriteString(strings.TrimSpace(skill.Body))
28+ if args = strings.TrimSpace(args); args != "" {
29+ sb.WriteString("\n\n---\n\nUser request:\n")
30+ sb.WriteString(args)
31+ }
32+ return sb.String()
33+}
@@ -0,0 +1,33 @@
1+package skills
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// InvocationPrompt expands a user-invoked skill into a prompt that includes
10+// its complete instructions. Arguments are appended as the request to perform.
11+func InvocationPrompt(skill *Skill, args string) string {
12+ if skill == nil {
13+ return ""
14+ }
15+
16+ var sb strings.Builder
17+ sb.WriteString("Use the following skill for this request. Follow its instructions.\n\n")
18+ fmt.Fprintf(&sb, "# Skill: %s\n\n", skill.Name)
19+ if desc := strings.TrimSpace(skill.Description); desc != "" {
20+ sb.WriteString(desc)
21+ sb.WriteString("\n\n")
22+ }
23+ if skill.Path != "" {
24+ fmt.Fprintf(&sb, "Skill directory: %s\n\n", filepath.Dir(skill.Path))
25+ }
26+ sb.WriteString("---\n\n")
27+ sb.WriteString(strings.TrimSpace(skill.Body))
28+ if args = strings.TrimSpace(args); args != "" {
29+ sb.WriteString("\n\n---\n\nUser request:\n")
30+ sb.WriteString(args)
31+ }
32+ return sb.String()
33+}
A packages/agent/skills/invocation_test.go +29 -0
@@ -0,0 +1,29 @@
1+package skills
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestInvocationPromptIncludesInstructionsLocationAndRequest(t *testing.T) {
10+ skill := &Skill{
11+ Name: "code-review",
12+ Description: "Review a change.",
13+ Body: "1. Read the diff.\n2. Report findings.",
14+ Path: filepath.Join("tmp", "skills", "code-review", "SKILL.md"),
15+ }
16+
17+ got := InvocationPrompt(skill, "review the current branch")
18+ for _, want := range []string{
19+ "Use the following skill for this request.",
20+ "# Skill: code-review",
21+ "Skill directory: " + filepath.Join("tmp", "skills", "code-review"),
22+ "1. Read the diff.",
23+ "User request:\nreview the current branch",
24+ } {
25+ if !strings.Contains(got, want) {
26+ t.Errorf("InvocationPrompt() missing %q:\n%s", want, got)
27+ }
28+ }
29+}
@@ -0,0 +1,29 @@
1+package skills
2+
3+import (
4+ "path/filepath"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestInvocationPromptIncludesInstructionsLocationAndRequest(t *testing.T) {
10+ skill := &Skill{
11+ Name: "code-review",
12+ Description: "Review a change.",
13+ Body: "1. Read the diff.\n2. Report findings.",
14+ Path: filepath.Join("tmp", "skills", "code-review", "SKILL.md"),
15+ }
16+
17+ got := InvocationPrompt(skill, "review the current branch")
18+ for _, want := range []string{
19+ "Use the following skill for this request.",
20+ "# Skill: code-review",
21+ "Skill directory: " + filepath.Join("tmp", "skills", "code-review"),
22+ "1. Read the diff.",
23+ "User request:\nreview the current branch",
24+ } {
25+ if !strings.Contains(got, want) {
26+ t.Errorf("InvocationPrompt() missing %q:\n%s", want, got)
27+ }
28+ }
29+}
M packages/agent/skills/skills.go +13 -4
@@ -61,6 +61,11 @@ type Skill struct {
6161 // shipped in their project.
6262 Builtin bool
6363
64+ // DisableModelInvocation hides the skill from the system-prompt
65+ // manifest. The user can still load it explicitly with
66+ // /skill:<name>.
67+ DisableModelInvocation bool
68+
6469 // AllowedTools and Permissions are parsed for forward-
6570 // compatibility but NOT enforced in this version. They appear
6671 // in the skill body so the model can self-regulate.
@@ -166,13 +171,15 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err
166171 // honestly in explanations and (b) distinguish between built-ins
167172 // and user-authored instruction sets when reasoning about trust.
168173 func SystemPromptAddendum(skills []*Skill) string {
169- if len(skills) == 0 {
170- return ""
171- }
172174 home, _ := os.UserHomeDir()
173175 var sb strings.Builder
174- sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
175176 for _, s := range skills {
177+ if s == nil || s.DisableModelInvocation {
178+ continue
179+ }
180+ if sb.Len() == 0 {
181+ sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
182+ }
176183 desc := strings.TrimSpace(s.Description)
177184 if desc == "" {
178185 desc = "(no description)"
@@ -326,6 +333,8 @@ func parseFrontmatter(front string, s *Skill) {
326333 s.Name = unquote(value)
327334 case "description":
328335 s.Description = unquote(value)
336+ case "disable-model-invocation", "disable_model_invocation":
337+ s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
329338 case "allowed-tools", "allowed_tools":
330339 if value != "" {
331340 s.AllowedTools = parseInlineList(value)
@@ -61,6 +61,11 @@ type Skill struct {
61 // shipped in their project.61 // shipped in their project.
62 Builtin bool62 Builtin bool
63 63
64+ // DisableModelInvocation hides the skill from the system-prompt
65+ // manifest. The user can still load it explicitly with
66+ // /skill:<name>.
67+ DisableModelInvocation bool
68+
64 // AllowedTools and Permissions are parsed for forward-69 // AllowedTools and Permissions are parsed for forward-
65 // compatibility but NOT enforced in this version. They appear70 // compatibility but NOT enforced in this version. They appear
66 // in the skill body so the model can self-regulate.71 // in the skill body so the model can self-regulate.
@@ -166,13 +171,15 @@ func scanUserSkills(zotHome, cwd, userHome string, seen map[string]*Skill) []err
166 // honestly in explanations and (b) distinguish between built-ins171 // honestly in explanations and (b) distinguish between built-ins
167 // and user-authored instruction sets when reasoning about trust.172 // and user-authored instruction sets when reasoning about trust.
168 func SystemPromptAddendum(skills []*Skill) string {173 func SystemPromptAddendum(skills []*Skill) string {
169- if len(skills) == 0 {
170- return ""
171- }
172 home, _ := os.UserHomeDir()174 home, _ := os.UserHomeDir()
173 var sb strings.Builder175 var sb strings.Builder
174- sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
175 for _, s := range skills {176 for _, s := range skills {
177+ if s == nil || s.DisableModelInvocation {
178+ continue
179+ }
180+ if sb.Len() == 0 {
181+ sb.WriteString("Available skills (call the `skill` tool with a name from this list to load its full instructions):\n")
182+ }
176 desc := strings.TrimSpace(s.Description)183 desc := strings.TrimSpace(s.Description)
177 if desc == "" {184 if desc == "" {
178 desc = "(no description)"185 desc = "(no description)"
@@ -326,6 +333,8 @@ func parseFrontmatter(front string, s *Skill) {
326 s.Name = unquote(value)333 s.Name = unquote(value)
327 case "description":334 case "description":
328 s.Description = unquote(value)335 s.Description = unquote(value)
336+ case "disable-model-invocation", "disable_model_invocation":
337+ s.DisableModelInvocation = strings.EqualFold(unquote(value), "true")
329 case "allowed-tools", "allowed_tools":338 case "allowed-tools", "allowed_tools":
330 if value != "" {339 if value != "" {
331 s.AllowedTools = parseInlineList(value)340 s.AllowedTools = parseInlineList(value)
M packages/agent/skills/skills_test.go +16 -0
@@ -25,6 +25,7 @@ func TestSplitFrontmatter(t *testing.T) {
2525 func TestParseFrontmatter(t *testing.T) {
2626 front := `name: code-review
2727 description: "Review a recent change."
28+disable-model-invocation: true
2829 allowed-tools: [read, bash]
2930 permissions:
3031 bash: ["git diff*", "git log*"]
@@ -37,6 +38,9 @@ permissions:
3738 if s.Description != "Review a recent change." {
3839 t.Errorf("description = %q", s.Description)
3940 }
41+ if !s.DisableModelInvocation {
42+ t.Error("disable-model-invocation was not parsed")
43+ }
4044 if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
4145 t.Errorf("allowed-tools = %v", got)
4246 }
@@ -46,6 +50,7 @@ permissions:
4650 }
4751
4852 func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
53+ t.Setenv("ZOT_AGENT_SKILLS", "")
4954 tmp := t.TempDir()
5055 zotHome := filepath.Join(tmp, "home")
5156 cwd := filepath.Join(tmp, "proj")
@@ -110,6 +115,7 @@ func TestSystemPromptAddendum(t *testing.T) {
110115 skills := []*Skill{
111116 {Name: "built-a", Description: "Do A.", Builtin: true},
112117 {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
118+ {Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
113119 }
114120 out := SystemPromptAddendum(skills)
115121 if !contains(out, "- built-a [builtin]: Do A.\n") {
@@ -118,6 +124,16 @@ func TestSystemPromptAddendum(t *testing.T) {
118124 if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
119125 t.Errorf("user entry missing path pointer:\n%s", out)
120126 }
127+ if contains(out, "manual-c") {
128+ t.Errorf("manual skill leaked into system prompt:\n%s", out)
129+ }
130+}
131+
132+func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
133+ out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
134+ if out != "" {
135+ t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
136+ }
121137 }
122138
123139 func contains(s, sub string) bool {
@@ -25,6 +25,7 @@ func TestSplitFrontmatter(t *testing.T) {
25 func TestParseFrontmatter(t *testing.T) {25 func TestParseFrontmatter(t *testing.T) {
26 front := `name: code-review26 front := `name: code-review
27 description: "Review a recent change."27 description: "Review a recent change."
28+disable-model-invocation: true
28 allowed-tools: [read, bash]29 allowed-tools: [read, bash]
29 permissions:30 permissions:
30 bash: ["git diff*", "git log*"]31 bash: ["git diff*", "git log*"]
@@ -37,6 +38,9 @@ permissions:
37 if s.Description != "Review a recent change." {38 if s.Description != "Review a recent change." {
38 t.Errorf("description = %q", s.Description)39 t.Errorf("description = %q", s.Description)
39 }40 }
41+ if !s.DisableModelInvocation {
42+ t.Error("disable-model-invocation was not parsed")
43+ }
40 if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {44 if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" {
41 t.Errorf("allowed-tools = %v", got)45 t.Errorf("allowed-tools = %v", got)
42 }46 }
@@ -46,6 +50,7 @@ permissions:
46 }50 }
47 51
48 func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {52 func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) {
53+ t.Setenv("ZOT_AGENT_SKILLS", "")
49 tmp := t.TempDir()54 tmp := t.TempDir()
50 zotHome := filepath.Join(tmp, "home")55 zotHome := filepath.Join(tmp, "home")
51 cwd := filepath.Join(tmp, "proj")56 cwd := filepath.Join(tmp, "proj")
@@ -110,6 +115,7 @@ func TestSystemPromptAddendum(t *testing.T) {
110 skills := []*Skill{115 skills := []*Skill{
111 {Name: "built-a", Description: "Do A.", Builtin: true},116 {Name: "built-a", Description: "Do A.", Builtin: true},
112 {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},117 {Name: "user-b", Description: "Do B.", Path: "/tmp/skills/user-b/SKILL.md"},
118+ {Name: "manual-c", Description: "Do C.", DisableModelInvocation: true},
113 }119 }
114 out := SystemPromptAddendum(skills)120 out := SystemPromptAddendum(skills)
115 if !contains(out, "- built-a [builtin]: Do A.\n") {121 if !contains(out, "- built-a [builtin]: Do A.\n") {
@@ -118,6 +124,16 @@ func TestSystemPromptAddendum(t *testing.T) {
118 if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {124 if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") {
119 t.Errorf("user entry missing path pointer:\n%s", out)125 t.Errorf("user entry missing path pointer:\n%s", out)
120 }126 }
127+ if contains(out, "manual-c") {
128+ t.Errorf("manual skill leaked into system prompt:\n%s", out)
129+ }
130+}
131+
132+func TestSystemPromptAddendumEmptyWhenAllSkillsDisableModelInvocation(t *testing.T) {
133+ out := SystemPromptAddendum([]*Skill{{Name: "manual", DisableModelInvocation: true}})
134+ if out != "" {
135+ t.Fatalf("SystemPromptAddendum() = %q, want empty", out)
136+ }
121 }137 }
122 138
123 func contains(s, sub string) bool {139 func contains(s, sub string) bool {
M packages/core/agent.go +7 -0
@@ -471,6 +471,13 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
471471 "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",
472472 "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
473473 "timeout", "terminated", "unexpected eof", "transport failure",
474+ // OpenAI's ChatGPT/Codex backend returns this generic message (with a
475+ // request ID) for transient server failures and explicitly says
476+ // "You can retry your request".
477+ "an error occurred while processing your request",
478+ // Explicit retry guidance emitted by provider backends (OpenAI
479+ // Responses, AWS Bedrock stream exceptions) with varying prefixes.
480+ "you can retry your request", "try your request again", "please retry your request",
474481 }
475482 for _, needle := range needles {
476483 if strings.Contains(msg, needle) {
@@ -471,6 +471,13 @@ func (a *Agent) canRetryError(err error, attempt int) bool {
471 "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",471 "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers",
472 "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",472 "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out",
473 "timeout", "terminated", "unexpected eof", "transport failure",473 "timeout", "terminated", "unexpected eof", "transport failure",
474+ // OpenAI's ChatGPT/Codex backend returns this generic message (with a
475+ // request ID) for transient server failures and explicitly says
476+ // "You can retry your request".
477+ "an error occurred while processing your request",
478+ // Explicit retry guidance emitted by provider backends (OpenAI
479+ // Responses, AWS Bedrock stream exceptions) with varying prefixes.
480+ "you can retry your request", "try your request again", "please retry your request",
474 }481 }
475 for _, needle := range needles {482 for _, needle := range needles {
476 if strings.Contains(msg, needle) {483 if strings.Contains(msg, needle) {
M packages/core/agent_retry_test.go +44 -0
@@ -65,6 +65,50 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) {
6565 }
6666 }
6767
68+// codexRetryFakeClient reproduces the generic OpenAI Codex backend
69+// failure ("An error occurred while processing your request. You can
70+// retry your request, ...") on the first call and succeeds afterwards.
71+type codexRetryFakeClient struct {
72+ calls int32
73+}
74+
75+func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
76+
77+func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
78+ call := atomic.AddInt32(&c.calls, 1)
79+ out := make(chan provider.Event, 4)
80+ go func() {
81+ defer close(out)
82+ out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
83+ if call == 1 {
84+ out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("codex error: An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 60c8ebbd-20bd-42e4-b756-6e844041cfc0 in your message.")}
85+ return
86+ }
87+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
88+ Role: provider.RoleAssistant,
89+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
90+ }}
91+ }()
92+ return out, nil
93+}
94+
95+func TestAgentRetriesCodexProcessingError(t *testing.T) {
96+ client := &codexRetryFakeClient{}
97+ a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
98+ a.RetryBaseDelay = time.Millisecond
99+
100+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
101+ t.Fatalf("Prompt returned %v; want retry to succeed", err)
102+ }
103+ if got := atomic.LoadInt32(&client.calls); got != 2 {
104+ t.Fatalf("Stream calls = %d; want 2", got)
105+ }
106+ msgs := a.Messages()
107+ if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
108+ t.Fatalf("messages = %v; want user + ok assistant", msgs)
109+ }
110+}
111+
68112 type partialRetryFakeClient struct {
69113 calls int32
70114 }
@@ -65,6 +65,50 @@ func TestAgentRetriesOverloadedStreamError(t *testing.T) {
65 }65 }
66 }66 }
67 67
68+// codexRetryFakeClient reproduces the generic OpenAI Codex backend
69+// failure ("An error occurred while processing your request. You can
70+// retry your request, ...") on the first call and succeeds afterwards.
71+type codexRetryFakeClient struct {
72+ calls int32
73+}
74+
75+func (c *codexRetryFakeClient) Name() string { return "openai-codex" }
76+
77+func (c *codexRetryFakeClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
78+ call := atomic.AddInt32(&c.calls, 1)
79+ out := make(chan provider.Event, 4)
80+ go func() {
81+ defer close(out)
82+ out <- provider.EventStart{Provider: "openai-codex", Model: req.Model}
83+ if call == 1 {
84+ out <- provider.EventDone{Stop: provider.StopError, Err: fmt.Errorf("codex error: An error occurred while processing your request. You can retry your request, or contact us through our help center at help.openai.com if the error persists. Please include the request ID 60c8ebbd-20bd-42e4-b756-6e844041cfc0 in your message.")}
85+ return
86+ }
87+ out <- provider.EventDone{Stop: provider.StopEnd, Message: provider.Message{
88+ Role: provider.RoleAssistant,
89+ Content: []provider.Content{provider.TextBlock{Text: "ok"}},
90+ }}
91+ }()
92+ return out, nil
93+}
94+
95+func TestAgentRetriesCodexProcessingError(t *testing.T) {
96+ client := &codexRetryFakeClient{}
97+ a := NewAgent(client, "gpt-5.6-sol", "system", Registry{})
98+ a.RetryBaseDelay = time.Millisecond
99+
100+ if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
101+ t.Fatalf("Prompt returned %v; want retry to succeed", err)
102+ }
103+ if got := atomic.LoadInt32(&client.calls); got != 2 {
104+ t.Fatalf("Stream calls = %d; want 2", got)
105+ }
106+ msgs := a.Messages()
107+ if len(msgs) != 2 || extractText(msgs[1]) != "ok" {
108+ t.Fatalf("messages = %v; want user + ok assistant", msgs)
109+ }
110+}
111+
68 type partialRetryFakeClient struct {112 type partialRetryFakeClient struct {
69 calls int32113 calls int32
70 }114 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close