Merge branch 'main' into octo/20260729-model-add-recvqd13uNrAsi
12 files · +324 -19
M docs/skills.md +23 -12
| @@ -1,8 +1,9 @@ | |||
| 1 | 1 | # zot skills | |
| 2 | 2 | ||
| 3 | 3 | 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: | |
| 6 | 7 | ||
| 7 | 8 | 1. The system prompt gains a short manifest: | |
| 8 | 9 | `Available skills: ... - code-review — Run a self-review pass...` | |
| @@ -35,6 +36,7 @@ When asked to review code, ... | |||
| 35 | 36 | |---|---|---| | |
| 36 | 37 | | `name` | optional | skill identifier; defaults to the directory name | | |
| 37 | 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 | 40 | | `allowed-tools` | optional | list of tool names the skill is meant to use; informational | | |
| 39 | 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 | 76 | description and source path. Press enter on a row to view the full | |
| 75 | 77 | body inline. Press esc to go back. | |
| 76 | 78 | ||
| 77 | - | ## How the model uses a skill | |
| 79 | + | ## Invoking skills | |
| 78 | 80 | ||
| 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. | |
| 85 | 84 | ||
| 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. | |
| 89 | 100 | ||
| 90 | 101 | ## Writing good skills | |
| 91 | 102 | ||
| @@ -1,8 +1,9 @@ | |||||
| 1 | # zot skills | 1 | # zot skills | ||
| 2 | 2 | ||||
| 3 | A skill is a reusable instruction set written as a single | 3 | A skill is a reusable instruction set written as a single | ||
| 4 | - | `SKILL.md` file with a YAML frontmatter header. zot discovers skills | 4 | + | `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 full | 76 | 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 skill | 79 | + | ## Invoking skills |
| 78 | 80 | ||||
| 79 | - | 1. The system prompt tells the model that skills exist and what | 81 | + | 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 and | 83 | + | 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 the | 85 | + | To force a specific skill, invoke it as a slash command: |
| 87 | - | code-review skill") but you don't have to — the descriptions in the | 86 | + | |
| 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 skills | 101 | ## Writing good skills | ||
| 91 | 102 | ||||
M packages/agent/modes/interactive.go +51 -0
| @@ -4161,6 +4161,27 @@ func longestCommonPrefix(ss []string) string { | |||
| 4161 | 4161 | ||
| 4162 | 4162 | func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |
| 4163 | 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 | 4185 | switch strings.ToLower(parts[0]) { | |
| 4165 | 4186 | case "/exit": | |
| 4166 | 4187 | return true | |
| @@ -4787,6 +4808,36 @@ func (i *Interactive) openBtwDialog(args []string) { | |||
| 4787 | 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 | 4841 | // openSkillsDialog opens the skill inspector. The picker reflects | |
| 4791 | 4842 | // whatever SkillSnapshot returns at call time, so edits to a | |
| 4792 | 4843 | // 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 true | 4187 | 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 reflects | 4841 | // openSkillsDialog opens the skill inspector. The picker reflects | ||
| 4791 | // whatever SkillSnapshot returns at call time, so edits to a | 4842 | // 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 { | |||
| 122 | 122 | if end < len(d.skills) { | |
| 123 | 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 | 126 | out = append(out, frameRule(th, width)) | |
| 126 | 127 | return out | |
| 127 | 128 | } | |
| @@ -166,7 +167,11 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |||
| 166 | 167 | ||
| 167 | 168 | func formatSkillRow(s *skills.Skill, maxWidth int) string { | |
| 168 | 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 | 175 | room := maxWidth - len(left) - len(src) | |
| 171 | 176 | if room < 10 { | |
| 172 | 177 | 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 out | 127 | 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 = 10 | 177 | room = 10 | ||
M packages/agent/modes/slash_suggest.go +11 -2
| @@ -158,8 +158,8 @@ func (s *slashSuggester) baseCatalog() []slashCommand { | |||
| 158 | 158 | // ("/foo.bar/") so those can be sent to the model as-is. | |
| 159 | 159 | // | |
| 160 | 160 | // 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. | |
| 163 | 163 | func looksLikeSlashCommand(text string) bool { | |
| 164 | 164 | text = strings.TrimSpace(text) | |
| 165 | 165 | if len(text) < 2 || text[0] != '/' { | |
| @@ -172,6 +172,12 @@ func looksLikeSlashCommand(text string) bool { | |||
| 172 | 172 | if head == "" { | |
| 173 | 173 | 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 | 181 | for _, r := range head { | |
| 176 | 182 | if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && | |
| 177 | 183 | !(r >= '0' && r <= '9') && r != '-' && r != '_' { | |
| @@ -194,6 +200,9 @@ func isKnownSlashCommand(text string) bool { | |||
| 194 | 200 | if i := strings.IndexAny(text, " \t\n"); i >= 0 { | |
| 195 | 201 | head = text[:i] | |
| 196 | 202 | } | |
| 203 | + | if len(head) >= len(skillCommandPrefix) && strings.EqualFold(head[:len(skillCommandPrefix)], skillCommandPrefix) { | |
| 204 | + | return true | |
| 205 | + | } | |
| 197 | 206 | for _, c := range slashCatalog { | |
| 198 | 207 | if strings.EqualFold(c.Name, head) { | |
| 199 | 208 | 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 false | 173 | 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 true | 208 | 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 { | |||
| 61 | 61 | // shipped in their project. | |
| 62 | 62 | 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 | 69 | // AllowedTools and Permissions are parsed for forward- | |
| 65 | 70 | // compatibility but NOT enforced in this version. They appear | |
| 66 | 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 | 171 | // honestly in explanations and (b) distinguish between built-ins | |
| 167 | 172 | // and user-authored instruction sets when reasoning about trust. | |
| 168 | 173 | func SystemPromptAddendum(skills []*Skill) string { | |
| 169 | - | if len(skills) == 0 { | |
| 170 | - | return "" | |
| 171 | - | } | |
| 172 | 174 | home, _ := os.UserHomeDir() | |
| 173 | 175 | 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 | 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 | 183 | desc := strings.TrimSpace(s.Description) | |
| 177 | 184 | if desc == "" { | |
| 178 | 185 | desc = "(no description)" | |
| @@ -326,6 +333,8 @@ func parseFrontmatter(front string, s *Skill) { | |||
| 326 | 333 | s.Name = unquote(value) | |
| 327 | 334 | case "description": | |
| 328 | 335 | s.Description = unquote(value) | |
| 336 | + | case "disable-model-invocation", "disable_model_invocation": | |
| 337 | + | s.DisableModelInvocation = strings.EqualFold(unquote(value), "true") | |
| 329 | 338 | case "allowed-tools", "allowed_tools": | |
| 330 | 339 | if value != "" { | |
| 331 | 340 | s.AllowedTools = parseInlineList(value) | |
| @@ -61,6 +61,11 @@ type Skill struct { | |||||
| 61 | // shipped in their project. | 61 | // shipped in their project. | ||
| 62 | Builtin bool | 62 | 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 appear | 70 | // 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-ins | 171 | // 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.Builder | 175 | 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) { | |||
| 25 | 25 | func TestParseFrontmatter(t *testing.T) { | |
| 26 | 26 | front := `name: code-review | |
| 27 | 27 | description: "Review a recent change." | |
| 28 | + | disable-model-invocation: true | |
| 28 | 29 | allowed-tools: [read, bash] | |
| 29 | 30 | permissions: | |
| 30 | 31 | bash: ["git diff*", "git log*"] | |
| @@ -37,6 +38,9 @@ permissions: | |||
| 37 | 38 | if s.Description != "Review a recent change." { | |
| 38 | 39 | t.Errorf("description = %q", s.Description) | |
| 39 | 40 | } | |
| 41 | + | if !s.DisableModelInvocation { | |
| 42 | + | t.Error("disable-model-invocation was not parsed") | |
| 43 | + | } | |
| 40 | 44 | if got := s.AllowedTools; len(got) != 2 || got[0] != "read" || got[1] != "bash" { | |
| 41 | 45 | t.Errorf("allowed-tools = %v", got) | |
| 42 | 46 | } | |
| @@ -46,6 +50,7 @@ permissions: | |||
| 46 | 50 | } | |
| 47 | 51 | ||
| 48 | 52 | func TestDiscoverProjectAndGlobalPriorityAndDedup(t *testing.T) { | |
| 53 | + | t.Setenv("ZOT_AGENT_SKILLS", "") | |
| 49 | 54 | tmp := t.TempDir() | |
| 50 | 55 | zotHome := filepath.Join(tmp, "home") | |
| 51 | 56 | cwd := filepath.Join(tmp, "proj") | |
| @@ -110,6 +115,7 @@ func TestSystemPromptAddendum(t *testing.T) { | |||
| 110 | 115 | skills := []*Skill{ | |
| 111 | 116 | {Name: "built-a", Description: "Do A.", Builtin: true}, | |
| 112 | 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 | 120 | out := SystemPromptAddendum(skills) | |
| 115 | 121 | if !contains(out, "- built-a [builtin]: Do A.\n") { | |
| @@ -118,6 +124,16 @@ func TestSystemPromptAddendum(t *testing.T) { | |||
| 118 | 124 | if !contains(out, "- user-b [/tmp/skills/user-b/SKILL.md]: Do B.\n") { | |
| 119 | 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 | 139 | 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-review | 26 | 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 { | |||
| 471 | 471 | "connection refused", "connection lost", "fetch failed", "upstream connect", "reset before headers", | |
| 472 | 472 | "socket hang up", "ended without", "stream ended before", "did not get a response", "timed out", | |
| 473 | 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 | 482 | for _, needle := range needles { | |
| 476 | 483 | 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) { | |||
| 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 | 112 | type partialRetryFakeClient struct { | |
| 69 | 113 | calls int32 | |
| 70 | 114 | } | |
| @@ -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 int32 | 113 | calls int32 | ||
| 70 | } | 114 | } | ||