agent: skills system (#17203)

This commit is contained in:
Parth Sareen
2026-07-17 10:32:22 -07:00
committed by GitHub
parent 794a254111
commit 573386c35e
20 changed files with 1490 additions and 77 deletions
+63 -25
View File
@@ -23,22 +23,60 @@ const (
EventError EventType = "error"
)
// ToolStatus is the typed lifecycle state for a tool call, carried on
// Event.ToolStatus for tool events.
type ToolStatus string
const (
ToolStatusRunning ToolStatus = "running"
ToolStatusDone ToolStatus = "done"
ToolStatusFailed ToolStatus = "failed"
ToolStatusDenied ToolStatus = "denied"
ToolStatusDisabled ToolStatus = "disabled"
ToolStatusSkipped ToolStatus = "skipped"
)
// RunStatus is the typed terminal outcome of a run, carried on Event.Status for
// run_finished events.
type RunStatus string
const (
RunStatusDone RunStatus = "done"
RunStatusDenied RunStatus = "denied"
RunStatusCanceled RunStatus = "canceled"
)
// CompactionTrigger is the typed reason a compaction ran or was attempted,
// carried on Event.CompactionTrigger for compaction events.
type CompactionTrigger string
const (
CompactionTriggerForce CompactionTrigger = "force"
CompactionTriggerPromptEval CompactionTrigger = "prompt_eval"
CompactionTriggerEstimate CompactionTrigger = "estimate"
CompactionTriggerToolOutput CompactionTrigger = "tool_output"
CompactionTriggerError CompactionTrigger = "error"
CompactionTriggerDue CompactionTrigger = "due"
)
type Event struct {
Type EventType `json:"type"`
RunID string `json:"runId,omitempty"`
ChatID string `json:"chatId,omitempty"`
Model string `json:"model,omitempty"`
Status string `json:"status,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ToolName string `json:"toolName,omitempty"`
WorkingDir string `json:"workingDir,omitempty"`
Content string `json:"content,omitempty"`
Thinking string `json:"thinking,omitempty"`
ToolCalls []api.ToolCall `json:"toolCalls,omitempty"`
Messages []api.Message `json:"messages,omitempty"`
Args map[string]any `json:"args,omitempty"`
Tokens int `json:"tokens,omitempty"`
Error string `json:"error,omitempty"`
Type EventType `json:"type"`
RunID string `json:"runId,omitempty"`
ChatID string `json:"chatId,omitempty"`
Model string `json:"model,omitempty"`
Status RunStatus `json:"status,omitempty"`
ToolStatus ToolStatus `json:"toolStatus,omitempty"`
CompactionTrigger CompactionTrigger `json:"compactionTrigger,omitempty"`
ToolCallID string `json:"toolCallId,omitempty"`
ToolName string `json:"toolName,omitempty"`
WorkingDir string `json:"workingDir,omitempty"`
Content string `json:"content,omitempty"`
Thinking string `json:"thinking,omitempty"`
ToolCalls []api.ToolCall `json:"toolCalls,omitempty"`
Messages []api.Message `json:"messages,omitempty"`
Args map[string]any `json:"args,omitempty"`
Tokens int `json:"tokens,omitempty"`
Error string `json:"error,omitempty"`
}
type EventSink interface {
@@ -78,18 +116,18 @@ func newToolCallDetected(m eventMetadata, calls []api.ToolCall) Event {
}
func newToolStarted(m eventMetadata, callID, toolName, workingDir string, args map[string]any) Event {
return Event{Type: EventToolStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: "running", ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args}
return Event{Type: EventToolStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: ToolStatusRunning, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args}
}
func newToolFinished(m eventMetadata, status, callID, toolName, workingDir string, args map[string]any, content, errMsg string) Event {
ev := Event{Type: EventToolFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args, Content: content}
func newToolFinished(m eventMetadata, status ToolStatus, callID, toolName, workingDir string, args map[string]any, content, errMsg string) Event {
ev := Event{Type: EventToolFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: status, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args, Content: content}
if errMsg != "" {
ev.Error = errMsg
}
return ev
}
func newRunFinished(m eventMetadata, status string) Event {
func newRunFinished(m eventMetadata, status RunStatus) Event {
return Event{Type: EventRunFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
}
@@ -101,16 +139,16 @@ func newCompactionProgress(m eventMetadata, tokens int) Event {
return Event{Type: EventCompactionProgress, RunID: m.runID, ChatID: m.chatID, Model: m.model, Tokens: tokens}
}
func newCompactionStarted(m eventMetadata, status string) Event {
return Event{Type: EventCompactionStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
func newCompactionStarted(m eventMetadata, trigger CompactionTrigger) Event {
return Event{Type: EventCompactionStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger}
}
func newCompactionSkipped(m eventMetadata, status, content string) Event {
return Event{Type: EventCompactionSkipped, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, Content: content}
func newCompactionSkipped(m eventMetadata, trigger CompactionTrigger, content string) Event {
return Event{Type: EventCompactionSkipped, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content}
}
func newCompacted(m eventMetadata, messages []api.Message, status, content string) Event {
return Event{Type: EventCompacted, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status, Content: content, Messages: messages}
func newCompacted(m eventMetadata, messages []api.Message, trigger CompactionTrigger, content string) Event {
return Event{Type: EventCompacted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content, Messages: messages}
}
func (s *Session) emit(event Event) error {
+34 -18
View File
@@ -23,6 +23,7 @@ type Session struct {
Client ChatClient
EventSinks []EventSink
Tools *Registry
Skills *SkillCatalog
DisableTools bool
ApprovalPrompter ApprovalPrompter
ApprovalState *ApprovalState
@@ -40,6 +41,9 @@ type RunOptions struct {
Options map[string]any
Think *api.ThinkValue
KeepAlive *api.Duration
// SkillName loads a catalog skill as an ordered synthetic tool call/result
// before the first model request for this run.
SkillName string
// MaxToolRounds limits consecutive model/tool cycles. A positive value is
// an explicit limit. Zero selects the model-specific default: local models
// use the default guard and cloud models are unlimited. A negative value
@@ -122,23 +126,23 @@ type runState struct {
}
type runFinish struct {
status string
status RunStatus
ignoreCanceled bool
err error
}
func (st *runState) finishDone() {
st.finish = runFinish{status: "done"}
st.finish = runFinish{status: RunStatusDone}
st.phase = runPhaseDone
}
func (st *runState) finishDenied() {
st.finish = runFinish{status: "denied"}
st.finish = runFinish{status: RunStatusDenied}
st.phase = runPhaseDone
}
func (st *runState) finishCanceled() {
st.finish = runFinish{status: "canceled", ignoreCanceled: true}
st.finish = runFinish{status: RunStatusCanceled, ignoreCanceled: true}
st.phase = runPhaseDone
}
@@ -159,6 +163,18 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
if err != nil {
return nil, err
}
activatedSkill, err := s.activateSkill(ctx, runID, opts)
if err != nil {
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
return nil, err
}
if len(activatedSkill) > 0 {
messages = append(messages, activatedSkill...)
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
return nil, err
}
}
st := runState{
runID: runID,
@@ -717,7 +733,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
if err != nil {
if result.Due && !skipNotified {
if trigger == "" {
trigger = "error"
trigger = CompactionTriggerError
}
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
skipNotified = true
@@ -727,7 +743,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
if !result.Compacted {
if result.Due && !skipNotified {
if trigger == "" {
trigger = "due"
trigger = CompactionTriggerDue
}
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
skipNotified = true
@@ -750,19 +766,19 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
req := s.compactionRequest(runID, opts, messages, latest)
req.Force = true
req.KeepUserTurns = &keepUserTurns
s.emitCompactionStarted(runID, opts, "tool_output")
s.emitCompactionStarted(runID, opts, CompactionTriggerToolOutput)
result, err := s.Compactor.MaybeCompact(ctx, req)
if err != nil {
if result.Due && !skipNotified {
s.emitCompactionSkipped(runID, opts, "tool_output", result.Reason)
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
}
if !result.Compacted {
if result.Due && !skipNotified {
s.emitCompactionSkipped(runID, opts, "tool_output", result.Reason)
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
skipNotified = true
}
return messages, skipNotified, nil
@@ -794,7 +810,7 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
batchTokens += estimateMessagesTokens([]api.Message{refit})
}
s.emitCompacted(runID, opts, compacted, "tool_output", result.Summary)
s.emitCompacted(runID, opts, compacted, CompactionTriggerToolOutput, result.Summary)
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
return compacted, skipNotified, err
}
@@ -821,25 +837,25 @@ func (s *Session) compactionRequest(runID string, opts RunOptions, messages []ap
}
}
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, status string) {
_ = s.emit(newCompactionStarted(newEventMetadata(runID, opts), status))
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, trigger CompactionTrigger) {
_ = s.emit(newCompactionStarted(newEventMetadata(runID, opts), trigger))
}
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, status, reason string) {
_ = s.emit(newCompactionSkipped(newEventMetadata(runID, opts), status, CompactionSkippedMessage(reason)))
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, trigger CompactionTrigger, reason string) {
_ = s.emit(newCompactionSkipped(newEventMetadata(runID, opts), trigger, CompactionSkippedMessage(reason)))
}
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, status, summary string) {
_ = s.emit(newCompacted(newEventMetadata(runID, opts), messages, status, summary))
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, trigger CompactionTrigger, summary string) {
_ = s.emit(newCompacted(newEventMetadata(runID, opts), messages, trigger, summary))
}
func (s *Session) autoCompactionTrigger(req CompactionRequest) string {
func (s *Session) autoCompactionTrigger(req CompactionRequest) CompactionTrigger {
if s.Compactor == nil {
return ""
}
trigger, should := s.Compactor.ShouldCompact(req)
if should {
return trigger
return CompactionTrigger(trigger)
}
return ""
}
+2 -2
View File
@@ -750,7 +750,7 @@ func TestSessionDisabledToolsOmitToolsAndReturnsDisabledResults(t *testing.T) {
if event.Type == EventToolCallDetected {
sawDetected = true
}
if event.Type == EventToolFinished && event.Status == "disabled" && event.Content == toolExecutionDisabledMessage {
if event.Type == EventToolFinished && event.ToolStatus == ToolStatusDisabled && event.Content == toolExecutionDisabledMessage {
sawDisabled = true
}
}
@@ -837,7 +837,7 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
if finished == nil {
t.Fatalf("run finished event missing: %#v", events.events)
}
if finished.Status != "canceled" {
if finished.Status != RunStatusCanceled {
t.Fatalf("run status = %q, want canceled", finished.Status)
}
}
+57
View File
@@ -0,0 +1,57 @@
package agent
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/ollama/ollama/api"
)
// activateSkill loads opts.SkillName from the catalog and injects a synthetic
// assistant tool call plus tool result before the first model request, so the
// transcript looks like a real skill tool invocation. It emits the same
// tool_call_detected -> tool_started -> tool_finished lifecycle the model path
// uses, and returns the messages to prepend. A blank SkillName is a no-op.
func (s *Session) activateSkill(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
name := strings.TrimSpace(opts.SkillName)
if name == "" {
return nil, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
skill, err := s.Skills.Load(name)
if err != nil {
return nil, err
}
args := api.NewToolCallFunctionArguments()
args.Set("name", skill.Name)
call := api.ToolCall{
ID: "call_skill_" + uuid.NewString(),
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
}
result := api.Message{
Role: "tool",
ToolName: "skill",
ToolCallID: call.ID,
Content: skill.Content(),
}
meta := newEventMetadata(runID, opts)
if err := s.emit(newToolCallDetected(meta, []api.ToolCall{call})); err != nil {
return nil, err
}
if err := s.emit(newToolStarted(meta, call.ID, "skill", s.currentWorkingDir(), args.ToMap())); err != nil {
return nil, err
}
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, ToolStatusDone, call.ID, "skill", s.currentWorkingDir(), args.ToMap(), result.Content, "")); err != nil {
return nil, err
}
return []api.Message{
{Role: "assistant", ToolCalls: []api.ToolCall{call}},
result,
}, nil
}
+74
View File
@@ -0,0 +1,74 @@
package agent
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ollama/ollama/api"
)
type skillTestClient struct{ requests []*api.ChatRequest }
func (c *skillTestClient) Chat(_ context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
c.requests = append(c.requests, req)
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "Done."}})
}
func testSkillCatalog(t *testing.T) *SkillCatalog {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "release-notes")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
return catalog
}
func TestSessionSkillActivationPreservesCallAndResultOrder(t *testing.T) {
catalog := testSkillCatalog(t)
client := &skillTestClient{}
events := &recordingEventSink{}
result, err := (&Session{Client: client, Skills: catalog, EventSinks: []EventSink{events}}).Run(context.Background(), RunOptions{
Model: "test",
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
SkillName: "release-notes",
})
if err != nil {
t.Fatal(err)
}
if len(result.Messages) != 4 {
t.Fatalf("transcript = %#v", result.Messages)
}
call, toolTranscript := result.Messages[1], result.Messages[2]
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "skill" || !strings.HasPrefix(call.ToolCalls[0].ID, "call_skill_") {
t.Fatalf("call message = %#v", call)
}
if toolTranscript.Role != "tool" || toolTranscript.ToolName != "skill" || toolTranscript.ToolCallID != call.ToolCalls[0].ID || !strings.Contains(toolTranscript.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v", toolTranscript)
}
if len(client.requests) != 1 || len(client.requests[0].Messages) != 3 || client.requests[0].Messages[2].ToolCallID != call.ToolCalls[0].ID {
t.Fatalf("model request did not preserve transcript: %#v", client.requests)
}
var skillEvents []EventType
for _, event := range events.events {
if event.ToolName == "skill" || event.Type == EventToolCallDetected {
skillEvents = append(skillEvents, event.Type)
}
}
if len(skillEvents) < 3 {
t.Fatalf("skill event order = %#v, want tool_call_detected,tool_started,tool_finished", skillEvents)
}
if got, want := strings.Join([]string{string(skillEvents[0]), string(skillEvents[1]), string(skillEvents[2])}, ","), "tool_call_detected,tool_started,tool_finished"; got != want {
t.Fatalf("skill event order = %#v, want %s", skillEvents, want)
}
}
+438
View File
@@ -0,0 +1,438 @@
package agent
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
const (
// SkillsDirEnv overrides the user-level Ollama-owned skills directory. The
// cross-client .agents/skills/ convention and project-level .ollama/skills/
// are also scanned (see LoadDefaultSkills); on a name collision, Ollama-owned
// directories take precedence over .agents/skills/, and project-level takes
// precedence over user-level.
SkillsDirEnv = "OLLAMA_SKILLS"
skillFilename = "SKILL.md"
maxSkillBytes = 1 << 20
bundledSkillCreatorName = "skill-creator"
bundledSkillCreatorContent = `---
name: skill-creator
description: Create or improve reusable skills. Use when the user wants a reusable skill, asks how to author SKILL.md, or needs help installing a skill.
---
# Create a skill
Create a focused, reusable instruction package. Treat a skill as guidance for the model, not as a way to gain new permissions or bypass safety controls.
## Choose the location
Create user skills beside this one. The skill directory shown in the loaded skill context is this skill's location; its parent is the user skill root. This bundled skill normally lives at ~/.ollama/skills/skill-creator, so new user skills normally go at ~/.ollama/skills/<skill-name>/SKILL.md.
Use a project-local skill directory only when the user asks to keep the skill with that project. Do not overwrite an existing skill without the user's approval. New and changed skills are discovered when the agent starts, so tell the user to begin a new agent session afterward.
## Follow the required shape
Use the directory name as the skill name. Use lowercase letters, numbers, and single hyphens only. Keep the name short and no longer than 64 characters.
Every skill needs a SKILL.md with YAML frontmatter followed by Markdown instructions:
~~~md
---
name: release-notes
description: Draft concise release notes from completed changes. Use when the user asks for a changelog, release notes, or GitHub release copy.
---
# Draft release notes
Write the workflow here.
~~~
Require a non-empty description that says both what the skill does and when to use it. Keep the body procedural and concise. Put detailed schemas, long examples, and variant-specific guidance in references/ only when the skill needs them.
Use scripts/ for repeatable or fragile operations that benefit from deterministic execution. Use assets/ for files that belong in generated output. Do not add README files, changelogs, or setup notes that do not help the model perform the task.
## Create safely
1. Identify the repeated task, expected inputs, and useful output.
2. Choose the smallest name and description that reliably trigger the skill.
3. Create the folder and SKILL.md; add resources only when they remove real repeated work.
4. Re-read the completed file and verify its frontmatter, directory-name match, and relative resource paths.
5. Tell the user where it was created and that a new agent session will discover it.
Skills provide instructions only. They do not grant filesystem, network, shell, or approval privileges, and they do not make a tool available. Use only the tools that are actually available, follow their normal approval rules, and ask before actions that need user authorization.
`
)
var skillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// SkillsDir returns the canonical runtime-owned skill directory.
func SkillsDir() (string, error) {
if path := strings.TrimSpace(os.Getenv(SkillsDirEnv)); path != "" {
return filepath.Abs(path)
}
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return filepath.Join(xdg, "ollama", "skills"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ollama", "skills"), nil
}
// Skill is a validated, loadable instruction set. It never grants tool
// permissions; it is supplied to the model as ordinary tool-result content.
type Skill struct {
Name string
Description string
Instructions string
Path string
}
func (s Skill) Content() string {
var b strings.Builder
fmt.Fprintf(&b, "<skill name=%q>\n%s\n", s.Name, strings.TrimSpace(s.Instructions))
if s.Path != "" {
dir := filepath.Dir(s.Path)
fmt.Fprintf(&b, "Skill directory: %s\n", dir)
b.WriteString("Relative paths in this skill are relative to the skill directory.\n")
}
if resources := s.resources(); len(resources) > 0 {
b.WriteString("<skill_resources>\n")
for _, r := range resources {
fmt.Fprintf(&b, " <file>%s</file>\n", r)
}
b.WriteString("</skill_resources>\n")
}
b.WriteString("</skill>")
return b.String()
}
// resources lists bundled files one level deep under scripts/, references/,
// and assets/ without reading them, so the model can load them on demand.
func (s Skill) resources() []string {
if s.Path == "" {
return nil
}
dir := filepath.Dir(s.Path)
var resources []string
for _, sub := range []string{"scripts", "references", "assets"} {
entries, err := os.ReadDir(filepath.Join(dir, sub))
if err != nil {
continue
}
for _, e := range entries {
if e.IsDir() {
continue
}
resources = append(resources, sub+"/"+e.Name())
}
}
sort.Strings(resources)
return resources
}
// SkillCatalog contains valid skills and diagnostics for ignored invalid
// entries, so one malformed skill cannot hide the rest.
type SkillCatalog struct {
dir string
skills map[string]Skill
diagnostics []error
}
func DiscoverSkills(dir string) (*SkillCatalog, error) {
dir, err := filepath.Abs(strings.TrimSpace(dir))
if err != nil {
return nil, err
}
catalog := &SkillCatalog{dir: dir, skills: make(map[string]Skill)}
entries, err := os.ReadDir(dir)
if errors.Is(err, fs.ErrNotExist) {
return catalog, nil
}
if err != nil {
return nil, fmt.Errorf("read skills directory: %w", err)
}
for _, entry := range entries {
name := entry.Name()
// Follow symlinks so users can point at shared skill repositories.
// The link name (not the target) is the canonical skill name.
info, err := os.Stat(filepath.Join(dir, name))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("skill %q: %w", name, err))
continue
}
if !info.IsDir() {
continue
}
if !skillName.MatchString(name) {
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("invalid skill directory %q", name))
continue
}
skill, err := parseSkill(filepath.Join(dir, name, skillFilename), name)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
catalog.diagnostics = append(catalog.diagnostics, err)
continue
}
catalog.skills[skill.Name] = skill
}
return catalog, nil
}
// LoadDefaultSkills discovers skills from the spec's scopes, merged with
// deterministic precedence. Roots are scanned lowest-precedence first so later
// roots override earlier ones on name collisions (recording a diagnostic):
//
// 1. ~/.agents/skills/ (user, cross-client)
// 2. user Ollama skills dir (user, Ollama-owned; SkillsDir)
// 3. <project>/.agents/skills/ (project, cross-client)
// 4. <project>/.ollama/skills/ (project, Ollama-owned)
//
// Project-level overrides user-level, and within a scope Ollama-owned
// directories override .agents/skills/. projectDir is the agent's working
// directory at startup (discovery is a session-start snapshot per the spec).
func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
roots, err := defaultSkillRoots(projectDir)
if err != nil {
return nil, err
}
catalog := &SkillCatalog{skills: make(map[string]Skill)}
bundled, err := bundledSkillCreator()
if err != nil {
return nil, err
}
catalog.skills[bundled.Name] = bundled
if err := installBundledSkillCreator(); err != nil {
catalog.diagnostics = append(catalog.diagnostics, err)
}
for _, root := range roots {
sub, err := DiscoverSkills(root.path)
if err != nil {
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("discover skills in %s: %w", root.path, err))
continue
}
catalog.diagnostics = append(catalog.diagnostics, sub.diagnostics...)
for _, skill := range sub.skills {
// Name collisions across roots are expected precedence resolution,
// not errors: later (higher-precedence) roots legitimately override
// earlier ones. The skill is still loaded; no diagnostic needed.
catalog.skills[skill.Name] = skill
}
}
return catalog, nil
}
func bundledSkillCreator() (Skill, error) {
skill, err := parseSkillContent("", bundledSkillCreatorName, bundledSkillCreatorContent)
if err != nil {
return Skill{}, fmt.Errorf("load bundled %s skill: %w", bundledSkillCreatorName, err)
}
return skill, nil
}
func installBundledSkillCreator() error {
dir, err := SkillsDir()
if err != nil {
return fmt.Errorf("resolve bundled skill directory: %w", err)
}
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundled skill directory: %w", err)
}
contents, err := os.ReadFile(path)
if err == nil && string(contents) == bundledSkillCreatorContent {
return nil
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read bundled skill: %w", err)
}
if err := os.WriteFile(path, []byte(bundledSkillCreatorContent), 0o644); err != nil {
return fmt.Errorf("write bundled skill: %w", err)
}
return nil
}
type skillRoot struct {
path string
}
// defaultSkillRoots returns skill directories ordered lowest- to
// highest-precedence. Non-existent directories are scanned harmlessly
// (DiscoverSkills skips them).
func defaultSkillRoots(projectDir string) ([]skillRoot, error) {
var roots []skillRoot
if home, err := os.UserHomeDir(); err == nil && home != "" {
roots = append(roots, skillRoot{path: filepath.Join(home, ".agents", "skills")})
}
userOllama, err := SkillsDir()
if err != nil {
return nil, err
}
roots = append(roots, skillRoot{path: userOllama})
projectDir = strings.TrimSpace(projectDir)
if projectDir != "" {
if abs, err := filepath.Abs(projectDir); err == nil {
roots = append(roots,
skillRoot{path: filepath.Join(abs, ".agents", "skills")},
skillRoot{path: filepath.Join(abs, ".ollama", "skills")},
)
}
}
return roots, nil
}
func (c *SkillCatalog) Dir() string {
if c == nil {
return ""
}
return c.dir
}
func (c *SkillCatalog) List() []Skill {
if c == nil {
return nil
}
list := make([]Skill, 0, len(c.skills))
for _, skill := range c.skills {
list = append(list, skill)
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
return list
}
func (c *SkillCatalog) Diagnostics() []error {
if c == nil {
return nil
}
return append([]error(nil), c.diagnostics...)
}
func (c *SkillCatalog) Load(name string) (Skill, error) {
name = strings.TrimSpace(name)
if !skillName.MatchString(name) {
return Skill{}, fmt.Errorf("invalid skill name %q", name)
}
if c == nil {
return Skill{}, errors.New("skills are unavailable")
}
skill, ok := c.skills[name]
if !ok {
return Skill{}, fmt.Errorf("skill %q not found in %s", name, c.dir)
}
return skill, nil
}
// SystemContext advertises the catalog without expanding full instructions in
// every request. The skill call is the explicit loading boundary.
func (c *SkillCatalog) SystemContext() string {
list := c.List()
if len(list) == 0 {
return ""
}
lines := []string{"<available_skills>"}
for _, skill := range list {
description := skill.Description
if description == "" {
description = "No description provided."
}
lines = append(lines, fmt.Sprintf("- %s: %s", skill.Name, description))
}
lines = append(lines, "</available_skills>", "Load a matching skill with the skill tool before following its instructions. Skills only provide instructions; use ordinary tools for filesystem or network access, with their normal approval rules.")
return strings.Join(lines, "\n")
}
func parseSkill(path, directoryName string) (Skill, error) {
// Stat (not Lstat) so a symlinked SKILL.md resolves to its target file.
info, err := os.Stat(path)
if err != nil {
return Skill{}, err
}
if !info.Mode().IsRegular() {
return Skill{}, fmt.Errorf("skill %q: %s is not a regular file", directoryName, skillFilename)
}
if info.Size() > maxSkillBytes {
return Skill{}, fmt.Errorf("skill %q: %s exceeds %d bytes", directoryName, skillFilename, maxSkillBytes)
}
data, err := os.ReadFile(path)
if err != nil {
return Skill{}, fmt.Errorf("read skill %q: %w", directoryName, err)
}
return parseSkillContent(path, directoryName, string(data))
}
func parseSkillContent(path, directoryName, input string) (Skill, error) {
instructions := strings.TrimSpace(input)
if instructions == "" {
return Skill{}, fmt.Errorf("skill %q: %s is empty", directoryName, skillFilename)
}
if !strings.HasPrefix(instructions, "---\n") && !strings.HasPrefix(instructions, "---\r\n") {
return Skill{}, fmt.Errorf("skill %q: missing YAML front matter", directoryName)
}
metadata, body, err := skillFrontMatter(instructions)
if err != nil {
return Skill{}, fmt.Errorf("skill %q: %w", directoryName, err)
}
if metadata.Name == "" {
return Skill{}, fmt.Errorf("skill %q: front matter requires name", directoryName)
}
if metadata.Description == "" {
return Skill{}, fmt.Errorf("skill %q: front matter requires description", directoryName)
}
if !skillName.MatchString(metadata.Name) {
return Skill{}, fmt.Errorf("skill %q: invalid front matter name %q", directoryName, metadata.Name)
}
if metadata.Name != directoryName {
return Skill{}, fmt.Errorf("skill %q: front matter name %q must match directory name", directoryName, metadata.Name)
}
skill := Skill{Name: metadata.Name, Description: metadata.Description, Path: path}
instructions = body
if strings.TrimSpace(instructions) == "" {
return Skill{}, fmt.Errorf("skill %q: instructions are empty", directoryName)
}
skill.Instructions = strings.TrimSpace(instructions)
return skill, nil
}
type skillFrontMatterMetadata struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Metadata map[string]any `yaml:"metadata"`
}
func skillFrontMatter(input string) (skillFrontMatterMetadata, string, error) {
input = strings.ReplaceAll(input, "\r\n", "\n")
lines := strings.Split(input, "\n")
if len(lines) < 3 || lines[0] != "---" {
return skillFrontMatterMetadata{}, "", errors.New("invalid front matter")
}
for i := 1; i < len(lines); i++ {
if lines[i] == "---" {
var metadata skillFrontMatterMetadata
if err := yaml.Unmarshal([]byte(strings.Join(lines[1:i], "\n")), &metadata); err != nil {
return skillFrontMatterMetadata{}, "", fmt.Errorf("parse YAML front matter: %w", err)
}
metadata.Name = strings.TrimSpace(metadata.Name)
metadata.Description = strings.TrimSpace(metadata.Description)
return metadata, strings.Join(lines[i+1:], "\n"), nil
}
}
return skillFrontMatterMetadata{}, "", errors.New("front matter is not closed")
}
+293
View File
@@ -0,0 +1,293 @@
package agent
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeCatalogSkill(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(content, "---") {
content = "---\nname: " + name + "\ndescription: Test skill.\n---\n" + content
}
if err := os.WriteFile(filepath.Join(path, skillFilename), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestDiscoverAndLoadSkills(t *testing.T) {
dir := t.TempDir()
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
list := catalog.List()
if len(list) != 1 || list[0].Name != "release-notes" || list[0].Description != "Draft concise release notes." {
t.Fatalf("skills = %#v", list)
}
skill, err := catalog.Load("release-notes")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(skill.Content(), `<skill name="release-notes">`) || !strings.Contains(skill.Content(), "Use short bullets.") {
t.Fatalf("skill content = %q", skill.Content())
}
if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Draft concise release notes.") || !strings.Contains(context, "normal approval rules") {
t.Fatalf("system context = %q", context)
}
}
func TestDiscoverSkillsSkipsMalformedEntries(t *testing.T) {
dir := t.TempDir()
writeCatalogSkill(t, dir, "valid", "do the useful thing")
writeCatalogSkill(t, dir, "mismatched", "---\nname: whatever\ndescription: wrong name\n---\nbody")
// Genuinely malformed front matter (a line without a key:value pair) is still rejected.
writeCatalogSkill(t, dir, "broken", "---\nname: broken\ndescription\n---\nnope")
writeCatalogSkill(t, dir, "missing-name", "---\ndescription: missing name\n---\nbody")
writeCatalogSkill(t, dir, "missing-description", "---\nname: missing-description\n---\nbody")
writeCatalogSkill(t, dir, "bad-name", "---\nname: bad_name\ndescription: invalid name\n---\nbody")
writeCatalogSkill(t, dir, "under_score", "---\nname: under_score\ndescription: invalid directory\n---\nbody")
if err := os.MkdirAll(filepath.Join(dir, "no-front-matter"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "no-front-matter", skillFilename), []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
if got, want := len(catalog.List()), 1; got != want {
t.Fatalf("valid skills = %d, want %d", got, want)
}
if got, want := len(catalog.Diagnostics()), 7; got != want {
t.Fatalf("diagnostics = %d, want %d: %#v", got, want, catalog.Diagnostics())
}
if _, err := catalog.Load("broken"); err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("load broken error = %v", err)
}
if _, err := catalog.Load("../valid"); err == nil || !strings.Contains(err.Error(), "invalid skill name") {
t.Fatalf("unsafe name error = %v", err)
}
}
func TestDiscoverSkillsFollowsSymlinks(t *testing.T) {
dir := t.TempDir()
target := t.TempDir()
writeCatalogSkill(t, target, "shared", "---\nname: shared\ndescription: From a linked repo.\n---\nshared instructions")
if err := os.Symlink(filepath.Join(target, "shared"), filepath.Join(dir, "shared")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
catalog, err := DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
list := catalog.List()
if len(list) != 1 || list[0].Name != "shared" || list[0].Description != "From a linked repo." {
t.Fatalf("symlinked skills = %#v", list)
}
if !strings.Contains(list[0].Content(), "shared instructions") {
t.Fatalf("symlinked skill content = %q", list[0].Content())
}
}
func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
project := t.TempDir()
writeCatalogSkill(t, filepath.Join(project, ".ollama", "skills"), "release-notes", "project instructions")
badRoot := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv(SkillsDirEnv, badRoot)
catalog, err := LoadDefaultSkills(project)
if err != nil {
t.Fatal(err)
}
if _, err := catalog.Load("release-notes"); err != nil {
t.Fatalf("valid skill was hidden by bad root: %v", err)
}
if _, err := catalog.Load(bundledSkillCreatorName); err != nil {
t.Fatalf("bundled skill was hidden by bad root: %v", err)
}
var foundDiagnostic bool
for _, diagnostic := range catalog.Diagnostics() {
if strings.Contains(diagnostic.Error(), badRoot) {
foundDiagnostic = true
break
}
}
if !foundDiagnostic {
t.Fatalf("diagnostics = %#v, want bad root %q", catalog.Diagnostics(), badRoot)
}
}
func TestLoadDefaultSkillsInstallsBundledSkillCreator(t *testing.T) {
dir := t.TempDir()
t.Setenv(SkillsDirEnv, dir)
catalog, err := LoadDefaultSkills("")
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load(bundledSkillCreatorName)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != bundledSkillCreatorContent {
t.Fatalf("installed skill = %q, want bundled contents", contents)
}
if skill.Path != path {
t.Fatalf("skill path = %q, want %q", skill.Path, path)
}
if !strings.Contains(skill.Content(), "Skill directory: "+filepath.Dir(path)) {
t.Fatalf("skill content does not identify its directory: %q", skill.Content())
}
}
func TestLoadDefaultSkillsUpdatesExistingSkillCreator(t *testing.T) {
dir := t.TempDir()
t.Setenv(SkillsDirEnv, dir)
writeCatalogSkill(t, dir, bundledSkillCreatorName, "custom instructions")
if _, err := LoadDefaultSkills(""); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(dir, bundledSkillCreatorName, skillFilename))
if err != nil {
t.Fatal(err)
}
if string(contents) != bundledSkillCreatorContent {
t.Fatalf("installed skill = %q, want bundled contents", contents)
}
}
func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
base := t.TempDir()
override := filepath.Join(base, "skills-override")
t.Setenv(SkillsDirEnv, override)
got, err := SkillsDir()
if err != nil {
t.Fatal(err)
}
want, err := filepath.Abs(override)
if err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("SkillsDir override = %q, want %q", got, want)
}
t.Setenv(SkillsDirEnv, "")
xdg := filepath.Join(base, "xdg")
t.Setenv("XDG_CONFIG_HOME", xdg)
if got, err := SkillsDir(); err != nil || got != filepath.Join(xdg, "ollama", "skills") {
t.Fatalf("SkillsDir xdg = %q, want %q, %v", got, filepath.Join(xdg, "ollama", "skills"), err)
}
t.Setenv("XDG_CONFIG_HOME", "")
home := filepath.Join(base, "home")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
if got, err := SkillsDir(); err != nil || got != filepath.Join(home, ".ollama", "skills") {
t.Fatalf("SkillsDir default = %q, want %q, %v", got, filepath.Join(home, ".ollama", "skills"), err)
}
}
func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir uses %USERPROFILE%
userOllama := t.TempDir()
t.Setenv(SkillsDirEnv, userOllama)
userAgents := filepath.Join(home, ".agents", "skills")
project := t.TempDir()
projectAgents := filepath.Join(project, ".agents", "skills")
projectOllama := filepath.Join(project, ".ollama", "skills")
// release-notes exists in all four roots; project ollama must win.
writeCatalogSkill(t, userAgents, "release-notes", "from user agents")
writeCatalogSkill(t, userOllama, "release-notes", "from user ollama")
writeCatalogSkill(t, projectOllama, "release-notes", "from project ollama")
// code-review exists in both project roots; project ollama beats project agents.
writeCatalogSkill(t, projectAgents, "code-review", "from project agents")
writeCatalogSkill(t, projectOllama, "code-review", "from project ollama")
// unique appears only in user ollama (via env override).
writeCatalogSkill(t, userOllama, "unique", "only here")
catalog, err := LoadDefaultSkills(project)
if err != nil {
t.Fatal(err)
}
rn, err := catalog.Load("release-notes")
if err != nil || !strings.Contains(rn.Instructions, "from project ollama") || !strings.Contains(rn.Path, ".ollama") {
t.Fatalf("release-notes = %#v, want project ollama to win", rn)
}
cr, err := catalog.Load("code-review")
if err != nil || !strings.Contains(cr.Instructions, "from project ollama") {
t.Fatalf("code-review = %#v, want project ollama to win over project agents", cr)
}
if _, err := catalog.Load("unique"); err != nil {
t.Fatalf("unique should load from user ollama: %v", err)
}
// Collisions are resolved silently by precedence — no diagnostics.
for _, d := range catalog.Diagnostics() {
if strings.Contains(d.Error(), "shadows") {
t.Fatalf("unexpected shadow diagnostic: %v", d)
}
}
}
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
root := t.TempDir()
skillDir := filepath.Join(root, "pdf-processing")
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pdf-processing\ndescription: Handle PDFs.\n---\nHandle PDFs."), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "extract.py"), []byte("#!/usr/bin/env python3"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "references", "ref.md"), []byte("ref"), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := DiscoverSkills(root)
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load("pdf-processing")
if err != nil {
t.Fatal(err)
}
content := skill.Content()
if !strings.Contains(content, "Skill directory:") || !strings.Contains(content, skillDir) {
t.Fatalf("content missing skill directory: %q", content)
}
if !strings.Contains(content, "<file>scripts/extract.py</file>") || !strings.Contains(content, "<file>references/ref.md</file>") {
t.Fatalf("content missing resource listing: %q", content)
}
}
+38
View File
@@ -0,0 +1,38 @@
package tools
import (
"context"
"errors"
"github.com/ollama/ollama/agent"
"github.com/ollama/ollama/api"
)
// Skill is the model-facing adapter for the core agent skill catalog.
// It only supplies instructions; regular tools retain their own approval
// requirements for filesystem or network access.
type Skill struct{ Catalog *agent.SkillCatalog }
func (t *Skill) Name() string { return "skill" }
func (t *Skill) Description() string {
return "Load a named Ollama skill and return its instructions."
}
func (t *Skill) Schema() api.ToolFunction {
props := api.NewToolPropertiesMap()
props.Set("name", api.ToolProperty{Type: api.PropertyType{"string"}, Description: "Name of the skill to load."})
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
}
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
name, ok := args["name"].(string)
if !ok {
return agent.ToolResult{}, errors.New("name parameter is required")
}
skill, err := t.Catalog.Load(name)
if err != nil {
return agent.ToolResult{}, err
}
return agent.ToolResult{Content: skill.Content()}, nil
}
+34
View File
@@ -0,0 +1,34 @@
package tools
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"github.com/ollama/ollama/agent"
)
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "release-notes")
if err := os.Mkdir(path, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := agent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
tool := &Skill{Catalog: catalog}
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
t.Fatal("loading a skill must not change ordinary tool approval semantics")
}
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("tool result = %#v, %v", result, err)
}
}
+28 -7
View File
@@ -220,16 +220,23 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return agentContextWindowForModel(ctx, client, model, fallback)
}
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
if err != nil {
return fmt.Errorf("load agent skills: %w", err)
}
for _, diagnostic := range skillCatalog.Diagnostics() {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
}
var registry *coreagent.Registry
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
return agentToolsRegistry(ctx, client, model)
return agentToolsRegistry(ctx, client, model, skillCatalog)
}
if opts.Model != "" {
registry = agentToolsRegistry(cmd.Context(), client, opts.Model)
registry = agentToolsRegistry(cmd.Context(), client, opts.Model, skillCatalog)
}
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, "", cwd)
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
Model: opts.Model,
Client: client,
Tools: registry,
@@ -244,9 +251,10 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
OnModelSelected: func(_ context.Context, model string) error {
return config.SetLastModel(model)
},
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), "", cwd)
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry, toolsDisabled bool) string {
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
},
Skills: skillCatalog,
SystemPrompt: systemPrompt,
WorkingDir: cwd,
Format: opts.Format,
@@ -284,6 +292,16 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return err
}
func agentSkillSystemContext(catalog *coreagent.SkillCatalog, registry *coreagent.Registry, toolsDisabled bool) string {
if toolsDisabled || registry == nil {
return ""
}
if _, ok := registry.Get("skill"); !ok {
return ""
}
return catalog.SystemContext()
}
func selectAgentModel(ctx context.Context, client *api.Client, current string) (string, error) {
models, err := agentModelOptions(ctx, client)
if err != nil {
@@ -384,7 +402,7 @@ func agentSystemFromShow(ctx context.Context, client *api.Client, modelName stri
return resp.System
}
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string) *coreagent.Registry {
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string, skillCatalog *coreagent.SkillCatalog) *coreagent.Registry {
supportsTools, err := agentModelSupportsTools(ctx, client, modelName)
if err != nil {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
@@ -399,6 +417,9 @@ func agentToolsRegistry(ctx context.Context, client *api.Client, modelName strin
}
registry.Register(&agenttools.Read{})
registry.Register(&agenttools.Edit{})
if len(skillCatalog.List()) > 0 {
registry.Register(&agenttools.Skill{Catalog: skillCatalog})
}
if os.Getenv("OLLAMA_AGENT_DISABLE_WEBSEARCH") == "" {
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
+48
View File
@@ -2,6 +2,8 @@ package cmd
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -9,6 +11,8 @@ import (
"github.com/spf13/cobra"
coreagent "github.com/ollama/ollama/agent"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
agentchat "github.com/ollama/ollama/cmd/tui/chat"
@@ -49,6 +53,50 @@ func TestAgentWorkingDirIgnoresGetwdFailure(t *testing.T) {
}
}
func TestAgentSystemPromptIncludesSkillCatalog(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "release-notes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "release-notes", "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft releases.\n---\nUse bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
got := agentSystemPromptAtWithWorkingDir(time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC), "model", "", catalog.SystemContext(), "")
if !strings.Contains(got, "release-notes: Draft releases.") || !strings.Contains(got, "normal approval rules") {
t.Fatalf("system prompt missing skill context: %q", got)
}
}
func TestAgentSkillSystemContextRequiresAvailableEnabledSkillTool(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "release-notes"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "release-notes", "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft releases.\n---\nUse bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
registry := &coreagent.Registry{}
registry.Register(&agenttools.Skill{Catalog: catalog})
if got := agentSkillSystemContext(catalog, registry, false); !strings.Contains(got, "release-notes: Draft releases.") {
t.Fatalf("enabled skill context = %q", got)
}
if got := agentSkillSystemContext(catalog, registry, true); got != "" {
t.Fatalf("disabled tools should omit skill context, got %q", got)
}
if got := agentSkillSystemContext(catalog, &coreagent.Registry{}, false); got != "" {
t.Fatalf("unavailable skill tool should omit skill context, got %q", got)
}
}
func TestAgentSelectionItemsUseLaunchSections(t *testing.T) {
items := agentSelectionItems([]agentchat.ModelOption{
{Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true},
+24 -3
View File
@@ -54,12 +54,13 @@ type Options struct {
Messages []api.Message
Client coreagent.ChatClient
Tools *coreagent.Registry
Skills *coreagent.SkillCatalog
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
ToolsDisabled bool
MultiModalForModel func(context.Context, string) bool
ModelOptions func(context.Context) ([]ModelOption, error)
OnModelSelected func(context.Context, string) error
SystemPromptForModel func(context.Context, string, *coreagent.Registry) string
SystemPromptForModel func(context.Context, string, *coreagent.Registry, bool) string
ApprovalPrompter coreagent.ApprovalPrompter
EventSinks []coreagent.EventSink
AllowAllTools bool
@@ -1014,7 +1015,7 @@ func (m *chatModel) startRun(input string) (tea.Model, tea.Cmd) {
m.status = "error"
return *m, nil
}
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "")
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "", "")
}
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
@@ -1073,7 +1074,25 @@ func pluralSuffix(count int) string {
return "s"
}
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
name = strings.TrimSpace(name)
prompt = strings.TrimSpace(prompt)
// The skill instructions are delivered via the synthetic tool result that
// follows this user turn, so this message orients the model rather than
// re-requesting the skill. When a prompt is supplied it becomes the task.
content := prompt
if content == "" {
content = "The " + name + " skill is loaded; follow its instructions for this request."
}
message := api.Message{Role: "user", Content: content}
displayInput := "/" + name
if prompt != "" {
displayInput = "/" + name + " " + prompt
}
return m.startRunWithMessages(displayInput, "", []api.Message{message}, "", name)
}
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
m.refreshContextWindowTokens(m.opts.Model)
m.addPromptHistory(historyInput)
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
@@ -1108,6 +1127,7 @@ func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newM
Client: m.opts.Client,
EventSinks: eventSinks,
Tools: m.opts.Tools,
Skills: m.opts.Skills,
DisableTools: m.opts.ToolsDisabled,
ApprovalPrompter: m.approvalPrompterForRun(m.approvalController),
ApprovalState: m.ensureApprovalState(),
@@ -1124,6 +1144,7 @@ func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newM
Options: m.opts.Options,
Think: m.opts.Think,
KeepAlive: m.opts.KeepAlive,
SkillName: skillName,
}
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
+5 -6
View File
@@ -233,16 +233,15 @@ func (m *chatModel) addDetectedToolCalls(calls []api.ToolCall) {
}
func toolFinishedStatus(event coreagent.Event) string {
switch strings.TrimSpace(event.Status) {
case "denied":
switch event.ToolStatus {
case coreagent.ToolStatusDenied:
return "denied"
case "disabled":
case coreagent.ToolStatusDisabled:
return "disabled"
case "done":
case coreagent.ToolStatusDone:
return "done"
case "error":
return "error"
}
// failed/skipped/unknown: derive from content and error fields.
if isDeniedToolResult(event.Content) || isDeniedToolResult(event.Error) {
return "denied"
}
+3 -3
View File
@@ -61,7 +61,7 @@ func TestApplyAgentEventRendersDeniedCommandAsDenied(t *testing.T) {
m.applyAgentEvent(coreagent.Event{
Type: coreagent.EventToolFinished,
Status: "denied",
ToolStatus: coreagent.ToolStatusDenied,
ToolCallID: "call-1",
ToolName: "bash",
Args: args,
@@ -341,8 +341,8 @@ func TestApplyAgentEventGroupsPreviouslyDeniedCommandsAtNextToolBoundary(t *test
secondArgs := map[string]any{"command": "ls"}
thirdArgs := map[string]any{"command": "date"}
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, Status: "denied", ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, Status: "denied", ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolStatus: coreagent.ToolStatusDenied, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolStatus: coreagent.ToolStatusDenied, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
if len(m.entries) != 2 {
t.Fatalf("entries = %d, want two stable denied command rows: %#v", len(m.entries), m.entries)
+107 -5
View File
@@ -56,6 +56,7 @@ var chatSlashCommands = []chatSlashCommand{
{name: "/new", description: "start a new chat"},
{name: "/think", description: "set thinking mode"},
{name: "/tools", description: "toggle tools on or off"},
{name: "/skills", description: "list available skills"},
{name: "/compact", description: "summarize older context"},
{name: "/help", description: "show commands", aliases: []string{"/?"}},
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
@@ -107,6 +108,7 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
if command != "" {
input = strings.TrimSpace(command + " " + args)
}
skillName, skillPrompt, skillOK := m.skillSlashInvocation(input)
switch {
case command == "/bye":
@@ -125,6 +127,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
return m.handleThinkCommand(args)
case command == "/tools":
return m.handleToolsCommand(args)
case command == "/skills":
return m.handleSkillsCommand(args)
case command == "/prompt":
return m.handlePromptCommand(args)
case command == "/save":
@@ -133,6 +137,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
return m.resetChat("new chat")
case command == "/compact" && args == "":
return m.startManualCompaction()
case skillOK:
return m.startSkillRun(skillName, skillPrompt)
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
return m.startRun(input)
case strings.HasPrefix(input, "/"):
@@ -143,6 +149,63 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
return m.startRun(input)
}
func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
if strings.TrimSpace(args) != "" {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills"}))
return *m, nil
}
skills := m.opts.Skills.List()
if len(skills) == 0 {
m.entries = append(m.entries, newSlashEntry("No skills found. Add directories containing SKILL.md under "+skillsDirForDisplay(m.opts.Skills)+"."))
return *m, nil
}
lines := []string{"Available skills:"}
for _, skill := range skills {
description := skill.Description
if description == "" {
description = "No description provided."
}
lines = append(lines, fmt.Sprintf("- `%s`: %s", skill.Name, description))
}
lines = append(lines, "\nType `/<name>` to load a skill into the conversation.")
m.entries = append(m.entries, newSlashEntry(strings.Join(lines, "\n")))
return *m, nil
}
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
if catalog != nil && catalog.Dir() != "" {
return catalog.Dir()
}
dir, err := coreagent.SkillsDir()
if err != nil {
return "the Ollama skills directory"
}
return dir
}
// skillSlashInvocation parses "/<skill-name>" or "/<skill-name> <prompt>".
// It returns the skill name, any trailing prompt, and ok when the first token is
// a catalog skill. Built-in slash commands take precedence over same-named
// skills, so they are never claimed here.
func (m *chatModel) skillSlashInvocation(input string) (name, prompt string, ok bool) {
input = strings.TrimSpace(input)
if !strings.HasPrefix(input, "/") {
return "", "", false
}
token, args, _ := strings.Cut(input, " ")
name = strings.TrimPrefix(token, "/")
if name == "" {
return "", "", false
}
if _, _, known := slashCommandInvocation(input); known {
return "", "", false
}
if _, err := m.opts.Skills.Load(name); err != nil {
return "", "", false
}
return name, strings.TrimSpace(args), true
}
func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
if strings.TrimSpace(args) != "" {
m.status = "error"
@@ -159,6 +222,9 @@ func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
m.opts.ToolsDisabled = true
m.status = "tools off"
}
if m.opts.SystemPromptForModel != nil {
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
}
return *m, nil
}
@@ -1041,16 +1107,16 @@ func (m chatModel) completions() []chatCompletion {
}
func (m chatModel) slashCompletions() []chatCompletion {
input := strings.TrimSpace(string(m.input))
rawInput := string(m.input)
input := strings.TrimSpace(rawInput)
if !strings.HasPrefix(input, "/") {
return nil
}
if m.skillSlashPromptStarted(rawInput) {
return nil
}
commands := matchingSlashCommands(input)
if len(commands) == 0 {
return []chatCompletion{{label: "No matching commands"}}
}
completions := make([]chatCompletion, 0, len(commands))
for _, command := range commands {
completions = append(completions, chatCompletion{
@@ -1059,9 +1125,45 @@ func (m chatModel) slashCompletions() []chatCompletion {
description: command.description,
})
}
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
// completions so they are discoverable by typing.
if m.opts.Skills != nil {
prefix := strings.ToLower(input)
for _, skill := range m.opts.Skills.List() {
name := "/" + skill.Name
if !strings.HasPrefix(name, prefix) {
continue
}
if _, _, known := slashCommandInvocation(name); known {
continue // built-in command wins; don't shadow it
}
description := skill.Description
if description == "" {
description = "No description provided."
}
completions = append(completions, chatCompletion{
value: name,
label: name,
description: description,
})
}
}
if len(completions) == 0 {
return []chatCompletion{{label: "No matching commands"}}
}
return completions
}
func (m chatModel) skillSlashPromptStarted(input string) bool {
input = strings.TrimLeftFunc(input, unicode.IsSpace)
end := strings.IndexFunc(input, unicode.IsSpace)
if end < 0 {
return false
}
_, _, ok := m.skillSlashInvocation(input[:end])
return ok
}
func matchingSlashCommands(input string) []chatSlashCommand {
prefix := strings.ToLower(strings.TrimSpace(input))
if prefix == "" {
+228 -3
View File
@@ -43,7 +43,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content)
}
}
for _, removed := range []string{"/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
for _, removed := range []string{"/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
if strings.Contains(fm.entries[0].content, removed) {
t.Fatalf("removed command %q should stay hidden from help:\n%s", removed, fm.entries[0].content)
}
@@ -511,8 +511,204 @@ func TestInitialPromptHistoryLoadsFromMessages(t *testing.T) {
}
}
func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
dir := t.TempDir()
skillDir := filepath.Join(dir, "release-notes")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
m := chatModel{opts: Options{Skills: catalog}, input: []rune("/skills")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("/skills should not start a model run")
}
m = updated.(chatModel)
if len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "release-notes") {
t.Fatalf("/skills entries = %#v", m.entries)
}
m = chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes")}
updated, cmd = m.handleSubmit()
if cmd == nil {
t.Fatal("/<skill-name> should continue the chat with the loaded instructions")
}
m = updated.(chatModel)
events := m.events
for {
msg, ok := <-events
if !ok {
t.Fatal("skill run closed before it finished")
}
updated, _ = m.Update(msg)
m = updated.(chatModel)
if _, ok := msg.(chatRunDoneMsg); ok {
break
}
}
if len(m.messages) != 4 {
t.Fatalf("synthetic messages = %#v", m.messages)
}
call := m.messages[1]
result := m.messages[2]
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "skill" || !strings.HasPrefix(call.ToolCalls[0].ID, "call_skill_") {
t.Fatalf("synthetic call = %#v", call)
}
if result.Role != "tool" || result.ToolCallID != call.ToolCalls[0].ID || !strings.Contains(result.Content, "Use concise bullets.") {
t.Fatalf("synthetic result = %#v", result)
}
entries := entriesFromMessages(m.messages)
if len(entries) != 3 || entries[1].toolID != call.ToolCalls[0].ID || entries[1].detail != "skill" || entries[1].args["name"] != "release-notes" {
t.Fatalf("round-trip entries = %#v", entries)
}
}
func TestSkillSlashCommandPromptBecomesUserMessage(t *testing.T) {
catalog := writeTestSkillCatalog(t)
m := chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes draft the v1.2 notes")}
updated, cmd := m.handleSubmit()
if cmd == nil {
t.Fatal("/<skill-name> <prompt> should start a run")
}
m = updated.(chatModel)
for {
msg, ok := <-m.events
if !ok {
t.Fatal("skill run closed before it finished")
}
updated, _ = m.Update(msg)
m = updated.(chatModel)
if _, ok := msg.(chatRunDoneMsg); ok {
break
}
}
if len(m.messages) < 1 || m.messages[0].Role != "user" || m.messages[0].Content != "draft the v1.2 notes" {
t.Fatalf("user message = %#v, want the prompt", m.messages[0])
}
// The skill still loads as a synthetic tool call right after the user turn.
if len(m.messages) < 3 || m.messages[1].Role != "assistant" || len(m.messages[1].ToolCalls) != 1 || m.messages[1].ToolCalls[0].Function.Name != "skill" {
t.Fatalf("synthetic skill call missing: %#v", m.messages)
}
}
func TestChatSkillSubmitWhileActiveRunKeepsActiveState(t *testing.T) {
catalog := writeTestSkillCatalog(t)
for _, state := range []struct {
name string
running bool
compacting bool
}{
{name: "running", running: true},
{name: "compacting", compacting: true},
} {
t.Run(state.name, func(t *testing.T) {
events := make(chan tea.Msg)
cancel := func() {}
m := chatModel{
opts: Options{Skills: catalog},
input: []rune("/release-notes draft notes"),
running: state.running,
compacting: state.compacting,
events: events,
cancel: cancel,
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("skill submit should not start another run while active")
}
got := updated.(chatModel)
if got.events != events || got.cancel == nil || got.running != state.running || got.compacting != state.compacting {
t.Fatalf("active run state changed: %#v", got)
}
if string(got.input) != "/release-notes draft notes" {
t.Fatalf("input = %q, want skill invocation preserved", got.input)
}
if got.status != "wait for current response" {
t.Fatalf("status = %q", got.status)
}
})
}
}
func writeTestSkillCatalog(t *testing.T) *coreagent.SkillCatalog {
t.Helper()
dir := t.TempDir()
skillDir := filepath.Join(dir, "release-notes")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
return catalog
}
func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
catalog := writeTestSkillCatalog(t)
m := chatModel{opts: Options{Skills: catalog}, input: []rune("/re")}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if !strings.Contains(lines, "/release-notes") || !strings.Contains(lines, "Draft release notes.") {
t.Fatalf("suggestions missing /release-notes: %q", lines)
}
}
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
catalog := writeTestSkillCatalog(t)
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
t.Run(input, func(t *testing.T) {
m := chatModel{opts: Options{Skills: catalog}, input: []rune(input)}
if lines := m.completionLines(80); len(lines) != 0 {
t.Fatalf("completion lines = %#v, want none", lines)
}
})
}
}
func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
catalog := writeTestSkillCatalog(t)
m := &chatModel{opts: Options{Skills: catalog}}
if name, _, ok := m.skillSlashInvocation("/release-notes"); !ok || name != "release-notes" {
t.Fatalf("/release-notes = %q %v, want release-notes true", name, ok)
}
if name, prompt, ok := m.skillSlashInvocation("/release-notes draft notes"); !ok || name != "release-notes" || prompt != "draft notes" {
t.Fatalf("/release-notes draft notes = %q %q %v, want release-notes / draft notes / true", name, prompt, ok)
}
if _, _, ok := m.skillSlashInvocation("/no-such-skill"); ok {
t.Fatal("unknown skill should not resolve")
}
// A built-in command sharing a prefix must not be claimed as a skill.
if _, _, ok := m.skillSlashInvocation("/skills"); ok {
t.Fatal("/skills should resolve to the built-in, not a skill")
}
// Unknown slash input that is not a skill stays an unknown command.
m2 := chatModel{opts: Options{Skills: catalog}, input: []rune("/no-such-skill")}
updated, cmd := m2.handleSubmit()
if cmd != nil {
t.Fatal("unknown slash command should not start a run")
}
m2 = updated.(chatModel)
if len(m2.entries) != 1 || m2.entries[0].role != "error" || !strings.Contains(m2.entries[0].content, "Unknown command") {
t.Fatalf("entries = %#v, want unknown command", m2.entries)
}
}
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
t.Run(command, func(t *testing.T) {
m := chatModel{input: []rune(command)}
@@ -541,7 +737,7 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
t.Fatalf("view missing %s suggestion: %q", want, view)
}
}
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
if strings.Contains(view, removed) {
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
}
@@ -605,6 +801,35 @@ func TestChatToolsCommandTogglesToolRegistry(t *testing.T) {
}
}
func TestChatToolsCommandRefreshesCapabilityAwareSystemPrompt(t *testing.T) {
registry := &coreagent.Registry{}
registry.Register(chatTestTool{})
m := chatModel{
ctx: context.Background(),
opts: Options{
Model: "test",
Tools: registry,
SystemPromptForModel: func(_ context.Context, _ string, _ *coreagent.Registry, disabled bool) string {
if disabled {
return "tools disabled"
}
return "tools enabled"
},
},
}
updated, _ := m.handleToolsCommand("")
m = updated.(chatModel)
if m.opts.SystemPrompt != "tools disabled" {
t.Fatalf("system prompt = %q, want disabled prompt", m.opts.SystemPrompt)
}
updated, _ = m.handleToolsCommand("")
m = updated.(chatModel)
if m.opts.SystemPrompt != "tools enabled" {
t.Fatalf("system prompt = %q, want enabled prompt", m.opts.SystemPrompt)
}
}
func TestChatToolsCommandUsage(t *testing.T) {
m := chatModel{input: []rune("/tools off")}
+1 -1
View File
@@ -213,7 +213,7 @@ func (m *chatModel) applyModelSelection(modelName string, persist bool) error {
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, modelName)
}
if m.opts.SystemPromptForModel != nil {
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, modelName, m.opts.Tools)
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, modelName, m.opts.Tools, m.opts.ToolsDisabled)
}
if m.opts.MultiModalForModel != nil {
ctx := m.ctx
+2 -2
View File
@@ -289,7 +289,7 @@ func TestChatModelPickerFiltersAndSwitchesModel(t *testing.T) {
}
return 262144
},
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry, toolsDisabled bool) string {
if model != "qwen3.5:cloud" {
t.Fatalf("system prompt model = %q, want qwen3.5:cloud", model)
}
@@ -397,7 +397,7 @@ func TestChatModelSwitchNextRunKeepsHistory(t *testing.T) {
opts: Options{
Model: "llama3.2",
Client: client,
SystemPromptForModel: func(_ context.Context, model string, _ *coreagent.Registry) string {
SystemPromptForModel: func(_ context.Context, model string, _ *coreagent.Registry, _ bool) string {
return "system for " + model
},
},
+2 -2
View File
@@ -1084,9 +1084,9 @@ func toolActionPhrase(action string, count int) string {
return "Fetched a URL"
case "skill":
if plural {
return fmt.Sprintf("Ran %d skills", count)
return fmt.Sprintf("Loaded %d skills", count)
}
return "Ran a skill"
return "Loaded a skill"
default:
if plural {
return fmt.Sprintf("Used %d tools", count)
+9
View File
@@ -1049,6 +1049,15 @@ func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) {
}
}
func TestToolActionPhraseLoadsSkills(t *testing.T) {
if got := toolActionPhrase("skill", 1); got != "Loaded a skill" {
t.Fatalf("single skill action = %q", got)
}
if got := toolActionPhrase("skill", 2); got != "Loaded 2 skills" {
t.Fatalf("multiple skill action = %q", got)
}
}
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
args := api.NewToolCallFunctionArguments()
args.Set("command", "pwd")