mirror of
https://github.com/ollama/ollama.git
synced 2026-01-20 05:18:31 -05:00
Compare commits
11 Commits
parth/decr
...
parth/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c2c2b8de9 | ||
|
|
5e23c4f2f7 | ||
|
|
5c0caaff86 | ||
|
|
e28ee8524d | ||
|
|
623e539a09 | ||
|
|
51911a5f6f | ||
|
|
2c2354e980 | ||
|
|
ce6b19d8be | ||
|
|
1de00fada0 | ||
|
|
7ecae75c4c | ||
|
|
ad5c276cf6 |
22
api/types.go
22
api/types.go
@@ -19,6 +19,12 @@ import (
|
|||||||
"github.com/ollama/ollama/types/model"
|
"github.com/ollama/ollama/types/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SkillRef is an alias for model.SkillRef representing a skill reference.
|
||||||
|
type SkillRef = model.SkillRef
|
||||||
|
|
||||||
|
// MCPRef is an alias for model.MCPRef representing an MCP server reference.
|
||||||
|
type MCPRef = model.MCPRef
|
||||||
|
|
||||||
// StatusError is an error with an HTTP status code and message.
|
// StatusError is an error with an HTTP status code and message.
|
||||||
type StatusError struct {
|
type StatusError struct {
|
||||||
StatusCode int
|
StatusCode int
|
||||||
@@ -690,6 +696,18 @@ type CreateRequest struct {
|
|||||||
// Requires is the minimum version of Ollama required by the model.
|
// Requires is the minimum version of Ollama required by the model.
|
||||||
Requires string `json:"requires,omitempty"`
|
Requires string `json:"requires,omitempty"`
|
||||||
|
|
||||||
|
// Skills is a list of skill references for the agent (local paths or registry refs)
|
||||||
|
Skills []SkillRef `json:"skills,omitempty"`
|
||||||
|
|
||||||
|
// MCPs is a list of MCP server references for the agent
|
||||||
|
MCPs []MCPRef `json:"mcps,omitempty"`
|
||||||
|
|
||||||
|
// AgentType defines the type of agent (e.g., "conversational", "task-based")
|
||||||
|
AgentType string `json:"agent_type,omitempty"`
|
||||||
|
|
||||||
|
// Entrypoint specifies an external command to run instead of the built-in chat loop
|
||||||
|
Entrypoint string `json:"entrypoint,omitempty"`
|
||||||
|
|
||||||
// Info is a map of additional information for the model
|
// Info is a map of additional information for the model
|
||||||
Info map[string]any `json:"info,omitempty"`
|
Info map[string]any `json:"info,omitempty"`
|
||||||
|
|
||||||
@@ -741,6 +759,10 @@ type ShowResponse struct {
|
|||||||
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
||||||
ModifiedAt time.Time `json:"modified_at,omitempty"`
|
ModifiedAt time.Time `json:"modified_at,omitempty"`
|
||||||
Requires string `json:"requires,omitempty"`
|
Requires string `json:"requires,omitempty"`
|
||||||
|
Skills []SkillRef `json:"skills,omitempty"`
|
||||||
|
MCPs []MCPRef `json:"mcps,omitempty"`
|
||||||
|
AgentType string `json:"agent_type,omitempty"`
|
||||||
|
Entrypoint string `json:"entrypoint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopyRequest is the request passed to [Client.Copy].
|
// CopyRequest is the request passed to [Client.Copy].
|
||||||
|
|||||||
402
cmd/agent_loop_test.go
Normal file
402
cmd/agent_loop_test.go
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestToolMessage verifies that tool messages are constructed correctly
|
||||||
|
// with ToolName and ToolCallID preserved from the tool call.
|
||||||
|
func TestToolMessage(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
call api.ToolCall
|
||||||
|
content string
|
||||||
|
expected api.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic tool message with ID",
|
||||||
|
call: api.ToolCall{
|
||||||
|
ID: "call_abc123",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{
|
||||||
|
"location": "Paris",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: "Sunny, 22°C",
|
||||||
|
expected: api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "Sunny, 22°C",
|
||||||
|
ToolName: "get_weather",
|
||||||
|
ToolCallID: "call_abc123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool message without ID",
|
||||||
|
call: api.ToolCall{
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "calculate",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{
|
||||||
|
"expression": "2+2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: "4",
|
||||||
|
expected: api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "4",
|
||||||
|
ToolName: "calculate",
|
||||||
|
// ToolCallID should be empty when call.ID is empty
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MCP tool message",
|
||||||
|
call: api.ToolCall{
|
||||||
|
ID: "call_mcp123",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "mcp_websearch_search",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{
|
||||||
|
"query": "ollama agents",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: "Found 10 results",
|
||||||
|
expected: api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "Found 10 results",
|
||||||
|
ToolName: "mcp_websearch_search",
|
||||||
|
ToolCallID: "call_mcp123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "skill tool message",
|
||||||
|
call: api.ToolCall{
|
||||||
|
ID: "call_skill456",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "run_skill_script",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{
|
||||||
|
"skill": "calculator",
|
||||||
|
"command": "python scripts/calc.py 2+2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: "Result: 4",
|
||||||
|
expected: api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "Result: 4",
|
||||||
|
ToolName: "run_skill_script",
|
||||||
|
ToolCallID: "call_skill456",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := toolMessage(tt.call, tt.content)
|
||||||
|
if diff := cmp.Diff(tt.expected, result); diff != "" {
|
||||||
|
t.Errorf("toolMessage() mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAssistantMessageWithThinking verifies that assistant messages
|
||||||
|
// in the tool loop should include thinking content.
|
||||||
|
func TestAssistantMessageConstruction(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
thinking string
|
||||||
|
toolCalls []api.ToolCall
|
||||||
|
expectedMsg api.Message
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "assistant with thinking and tool calls",
|
||||||
|
content: "",
|
||||||
|
thinking: "I need to check the weather for Paris.",
|
||||||
|
toolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "Paris"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMsg: api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
Thinking: "I need to check the weather for Paris.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_1",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "Paris"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "assistant with content, thinking, and tool calls",
|
||||||
|
content: "Let me check that for you.",
|
||||||
|
thinking: "User wants weather info.",
|
||||||
|
toolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_2",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "search",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"query": "weather"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMsg: api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "Let me check that for you.",
|
||||||
|
Thinking: "User wants weather info.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_2",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "search",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"query": "weather"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "assistant with multiple tool calls",
|
||||||
|
content: "",
|
||||||
|
thinking: "I'll check both cities.",
|
||||||
|
toolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_a",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "Paris"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "call_b",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "London"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMsg: api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
Thinking: "I'll check both cities.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{
|
||||||
|
ID: "call_a",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "Paris"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "call_b",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "get_weather",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{"city": "London"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Simulate the assistant message construction as done in chat()
|
||||||
|
assistantMsg := api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: tt.content,
|
||||||
|
Thinking: tt.thinking,
|
||||||
|
ToolCalls: tt.toolCalls,
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(tt.expectedMsg, assistantMsg); diff != "" {
|
||||||
|
t.Errorf("assistant message mismatch (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMessageStitchingOrder verifies that messages in a tool loop
|
||||||
|
// are stitched in the correct order:
|
||||||
|
// 1. User message
|
||||||
|
// 2. Assistant message with tool calls (and thinking)
|
||||||
|
// 3. Tool result messages (one per tool call, in order)
|
||||||
|
// 4. Next assistant response
|
||||||
|
func TestMessageStitchingOrder(t *testing.T) {
|
||||||
|
// Simulate a complete tool loop conversation
|
||||||
|
messages := []api.Message{
|
||||||
|
// Initial user message
|
||||||
|
{Role: "user", Content: "What's the weather in Paris and London?"},
|
||||||
|
// Assistant's first response with tool calls
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: "",
|
||||||
|
Thinking: "I need to check the weather for both cities.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{ID: "call_1", Function: api.ToolCallFunction{Name: "get_weather", Arguments: api.ToolCallFunctionArguments{"city": "Paris"}}},
|
||||||
|
{ID: "call_2", Function: api.ToolCallFunction{Name: "get_weather", Arguments: api.ToolCallFunctionArguments{"city": "London"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Tool results (in order matching tool calls)
|
||||||
|
{Role: "tool", Content: "Sunny, 22°C", ToolName: "get_weather", ToolCallID: "call_1"},
|
||||||
|
{Role: "tool", Content: "Rainy, 15°C", ToolName: "get_weather", ToolCallID: "call_2"},
|
||||||
|
// Final assistant response
|
||||||
|
{Role: "assistant", Content: "Paris is sunny at 22°C, and London is rainy at 15°C.", Thinking: "Got the data, now summarizing."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify structure
|
||||||
|
expectedRoles := []string{"user", "assistant", "tool", "tool", "assistant"}
|
||||||
|
for i, msg := range messages {
|
||||||
|
if msg.Role != expectedRoles[i] {
|
||||||
|
t.Errorf("message %d: expected role %q, got %q", i, expectedRoles[i], msg.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tool results match tool calls in order
|
||||||
|
assistantWithTools := messages[1]
|
||||||
|
toolResults := []api.Message{messages[2], messages[3]}
|
||||||
|
|
||||||
|
if len(toolResults) != len(assistantWithTools.ToolCalls) {
|
||||||
|
t.Errorf("expected %d tool results for %d tool calls", len(assistantWithTools.ToolCalls), len(toolResults))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, result := range toolResults {
|
||||||
|
expectedToolCallID := assistantWithTools.ToolCalls[i].ID
|
||||||
|
if result.ToolCallID != expectedToolCallID {
|
||||||
|
t.Errorf("tool result %d: expected ToolCallID %q, got %q", i, expectedToolCallID, result.ToolCallID)
|
||||||
|
}
|
||||||
|
expectedToolName := assistantWithTools.ToolCalls[i].Function.Name
|
||||||
|
if result.ToolName != expectedToolName {
|
||||||
|
t.Errorf("tool result %d: expected ToolName %q, got %q", i, expectedToolName, result.ToolName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify thinking is present in assistant messages
|
||||||
|
if messages[1].Thinking == "" {
|
||||||
|
t.Error("first assistant message should have thinking content")
|
||||||
|
}
|
||||||
|
if messages[4].Thinking == "" {
|
||||||
|
t.Error("final assistant message should have thinking content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMultiTurnToolLoop verifies message stitching across multiple
|
||||||
|
// tool call iterations.
|
||||||
|
func TestMultiTurnToolLoop(t *testing.T) {
|
||||||
|
messages := []api.Message{
|
||||||
|
{Role: "user", Content: "What's 2+2 and also what's the weather in Paris?"},
|
||||||
|
// First tool call: calculate
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Thinking: "I'll start with the calculation.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{ID: "calc_1", Function: api.ToolCallFunction{Name: "calculate", Arguments: api.ToolCallFunctionArguments{"expr": "2+2"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "4", ToolName: "calculate", ToolCallID: "calc_1"},
|
||||||
|
// Second tool call: weather
|
||||||
|
{
|
||||||
|
Role: "assistant",
|
||||||
|
Thinking: "Got the calculation. Now checking weather.",
|
||||||
|
ToolCalls: []api.ToolCall{
|
||||||
|
{ID: "weather_1", Function: api.ToolCallFunction{Name: "get_weather", Arguments: api.ToolCallFunctionArguments{"city": "Paris"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{Role: "tool", Content: "Sunny, 20°C", ToolName: "get_weather", ToolCallID: "weather_1"},
|
||||||
|
// Final response
|
||||||
|
{Role: "assistant", Content: "2+2 equals 4, and Paris is sunny at 20°C."},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count message types
|
||||||
|
roleCounts := map[string]int{}
|
||||||
|
for _, msg := range messages {
|
||||||
|
roleCounts[msg.Role]++
|
||||||
|
}
|
||||||
|
|
||||||
|
if roleCounts["user"] != 1 {
|
||||||
|
t.Errorf("expected 1 user message, got %d", roleCounts["user"])
|
||||||
|
}
|
||||||
|
if roleCounts["assistant"] != 3 {
|
||||||
|
t.Errorf("expected 3 assistant messages, got %d", roleCounts["assistant"])
|
||||||
|
}
|
||||||
|
if roleCounts["tool"] != 2 {
|
||||||
|
t.Errorf("expected 2 tool messages, got %d", roleCounts["tool"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each tool message follows an assistant with matching tool call
|
||||||
|
for i, msg := range messages {
|
||||||
|
if msg.Role == "tool" {
|
||||||
|
// Find preceding assistant message with tool calls
|
||||||
|
var precedingAssistant *api.Message
|
||||||
|
for j := i - 1; j >= 0; j-- {
|
||||||
|
if messages[j].Role == "assistant" && len(messages[j].ToolCalls) > 0 {
|
||||||
|
precedingAssistant = &messages[j]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if precedingAssistant == nil {
|
||||||
|
t.Errorf("tool message at index %d has no preceding assistant with tool calls", i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tool result matches one of the tool calls
|
||||||
|
found := false
|
||||||
|
for _, tc := range precedingAssistant.ToolCalls {
|
||||||
|
if tc.ID == msg.ToolCallID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("tool message at index %d has ToolCallID %q not found in preceding tool calls", i, msg.ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillCatalogRunToolCallPreservesFields tests that skill catalog
|
||||||
|
// returns tool messages with correct fields.
|
||||||
|
func TestSkillCatalogToolMessageFields(t *testing.T) {
|
||||||
|
// Create a minimal test for toolMessage function
|
||||||
|
call := api.ToolCall{
|
||||||
|
ID: "test_id_123",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "run_skill_script",
|
||||||
|
Arguments: api.ToolCallFunctionArguments{
|
||||||
|
"skill": "test-skill",
|
||||||
|
"command": "echo hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := toolMessage(call, "hello")
|
||||||
|
|
||||||
|
if msg.Role != "tool" {
|
||||||
|
t.Errorf("expected role 'tool', got %q", msg.Role)
|
||||||
|
}
|
||||||
|
if msg.Content != "hello" {
|
||||||
|
t.Errorf("expected content 'hello', got %q", msg.Content)
|
||||||
|
}
|
||||||
|
if msg.ToolName != "run_skill_script" {
|
||||||
|
t.Errorf("expected ToolName 'run_skill_script', got %q", msg.ToolName)
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "test_id_123" {
|
||||||
|
t.Errorf("expected ToolCallID 'test_id_123', got %q", msg.ToolCallID)
|
||||||
|
}
|
||||||
|
}
|
||||||
445
cmd/cmd.go
445
cmd/cmd.go
@@ -15,6 +15,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -495,6 +496,16 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
opts.ParentModel = info.Details.ParentModel
|
opts.ParentModel = info.Details.ParentModel
|
||||||
|
|
||||||
|
// Check if this is an agent
|
||||||
|
isAgent := info.AgentType != "" || len(info.Skills) > 0 || len(info.MCPs) > 0 || info.Entrypoint != ""
|
||||||
|
if isAgent {
|
||||||
|
opts.IsAgent = true
|
||||||
|
opts.AgentType = info.AgentType
|
||||||
|
opts.Skills = info.Skills
|
||||||
|
opts.MCPs = info.MCPs
|
||||||
|
opts.Entrypoint = info.Entrypoint
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is an embedding model
|
// Check if this is an embedding model
|
||||||
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding)
|
||||||
|
|
||||||
@@ -520,6 +531,10 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Check for experimental flag
|
// Check for experimental flag
|
||||||
isExperimental, _ := cmd.Flags().GetBool("experimental")
|
isExperimental, _ := cmd.Flags().GetBool("experimental")
|
||||||
|
// If agent has entrypoint, run it instead of chat loop
|
||||||
|
if opts.Entrypoint != "" {
|
||||||
|
return runEntrypoint(cmd, opts)
|
||||||
|
}
|
||||||
|
|
||||||
if interactive {
|
if interactive {
|
||||||
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
||||||
@@ -554,9 +569,62 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
return generateInteractive(cmd, opts)
|
return generateInteractive(cmd, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For agents, use chat API even in non-interactive mode to support tools
|
||||||
|
if opts.IsAgent {
|
||||||
|
opts.Messages = append(opts.Messages, api.Message{Role: "user", Content: opts.Prompt})
|
||||||
|
_, err := chat(cmd, opts)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return generate(cmd, opts)
|
return generate(cmd, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runEntrypoint executes the agent's entrypoint command instead of the built-in chat loop.
|
||||||
|
func runEntrypoint(cmd *cobra.Command, opts runOptions) error {
|
||||||
|
entrypoint := opts.Entrypoint
|
||||||
|
|
||||||
|
// Check if entrypoint contains $PROMPT placeholder
|
||||||
|
hasPlaceholder := strings.Contains(entrypoint, "$PROMPT")
|
||||||
|
|
||||||
|
if hasPlaceholder && opts.Prompt != "" {
|
||||||
|
// Replace $PROMPT with the actual prompt
|
||||||
|
entrypoint = strings.ReplaceAll(entrypoint, "$PROMPT", opts.Prompt)
|
||||||
|
} else if hasPlaceholder {
|
||||||
|
// No prompt provided but placeholder exists - remove placeholder
|
||||||
|
entrypoint = strings.ReplaceAll(entrypoint, "$PROMPT", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse entrypoint into command and args
|
||||||
|
parts := strings.Fields(entrypoint)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return fmt.Errorf("empty entrypoint")
|
||||||
|
}
|
||||||
|
|
||||||
|
command := parts[0]
|
||||||
|
args := parts[1:]
|
||||||
|
|
||||||
|
// If user provided a prompt and no placeholder was used, append it as argument
|
||||||
|
if opts.Prompt != "" && !hasPlaceholder {
|
||||||
|
args = append(args, opts.Prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up command in PATH
|
||||||
|
execPath, err := exec.LookPath(command)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("entrypoint command not found: %s", command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create subprocess
|
||||||
|
proc := exec.Command(execPath, args...)
|
||||||
|
proc.Stdin = os.Stdin
|
||||||
|
proc.Stdout = os.Stdout
|
||||||
|
proc.Stderr = os.Stderr
|
||||||
|
|
||||||
|
// Run and wait
|
||||||
|
return proc.Run()
|
||||||
|
}
|
||||||
|
|
||||||
func SigninHandler(cmd *cobra.Command, args []string) error {
|
func SigninHandler(cmd *cobra.Command, args []string) error {
|
||||||
client, err := api.ClientFromEnvironment()
|
client, err := api.ClientFromEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -916,47 +984,96 @@ func showInfo(resp *api.ShowResponse, verbose bool, w io.Writer) error {
|
|||||||
fmt.Fprintln(w)
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
tableRender("Model", func() (rows [][]string) {
|
// Only show Model section if there's actual model info (not for entrypoint-only agents)
|
||||||
if resp.RemoteHost != "" {
|
hasModelInfo := resp.RemoteHost != "" || resp.ModelInfo != nil || resp.Details.Family != "" || resp.Details.ParameterSize != "" || resp.Details.QuantizationLevel != ""
|
||||||
rows = append(rows, []string{"", "Remote model", resp.RemoteModel})
|
if hasModelInfo {
|
||||||
rows = append(rows, []string{"", "Remote URL", resp.RemoteHost})
|
tableRender("Model", func() (rows [][]string) {
|
||||||
}
|
if resp.RemoteHost != "" {
|
||||||
|
rows = append(rows, []string{"", "Remote model", resp.RemoteModel})
|
||||||
if resp.ModelInfo != nil {
|
rows = append(rows, []string{"", "Remote URL", resp.RemoteHost})
|
||||||
arch := resp.ModelInfo["general.architecture"].(string)
|
|
||||||
rows = append(rows, []string{"", "architecture", arch})
|
|
||||||
|
|
||||||
var paramStr string
|
|
||||||
if resp.Details.ParameterSize != "" {
|
|
||||||
paramStr = resp.Details.ParameterSize
|
|
||||||
} else if v, ok := resp.ModelInfo["general.parameter_count"]; ok {
|
|
||||||
if f, ok := v.(float64); ok {
|
|
||||||
paramStr = format.HumanNumber(uint64(f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, []string{"", "parameters", paramStr})
|
|
||||||
|
|
||||||
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)]; ok {
|
|
||||||
if f, ok := v.(float64); ok {
|
|
||||||
rows = append(rows, []string{"", "context length", strconv.FormatFloat(f, 'f', -1, 64)})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.embedding_length", arch)]; ok {
|
if resp.ModelInfo != nil {
|
||||||
if f, ok := v.(float64); ok {
|
arch := resp.ModelInfo["general.architecture"].(string)
|
||||||
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(f, 'f', -1, 64)})
|
rows = append(rows, []string{"", "architecture", arch})
|
||||||
|
|
||||||
|
var paramStr string
|
||||||
|
if resp.Details.ParameterSize != "" {
|
||||||
|
paramStr = resp.Details.ParameterSize
|
||||||
|
} else if v, ok := resp.ModelInfo["general.parameter_count"]; ok {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
paramStr = format.HumanNumber(uint64(f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"", "parameters", paramStr})
|
||||||
|
|
||||||
|
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)]; ok {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
rows = append(rows, []string{"", "context length", strconv.FormatFloat(f, 'f', -1, 64)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := resp.ModelInfo[fmt.Sprintf("%s.embedding_length", arch)]; ok {
|
||||||
|
if f, ok := v.(float64); ok {
|
||||||
|
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(f, 'f', -1, 64)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows = append(rows, []string{"", "architecture", resp.Details.Family})
|
||||||
|
rows = append(rows, []string{"", "parameters", resp.Details.ParameterSize})
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"", "quantization", resp.Details.QuantizationLevel})
|
||||||
|
if resp.Requires != "" {
|
||||||
|
rows = append(rows, []string{"", "requires", resp.Requires})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display agent information if this is an agent
|
||||||
|
if resp.AgentType != "" || len(resp.Skills) > 0 || len(resp.MCPs) > 0 || resp.Entrypoint != "" {
|
||||||
|
tableRender("Agent", func() (rows [][]string) {
|
||||||
|
if resp.AgentType != "" {
|
||||||
|
rows = append(rows, []string{"", "type", resp.AgentType})
|
||||||
|
}
|
||||||
|
if resp.Entrypoint != "" {
|
||||||
|
rows = append(rows, []string{"", "entrypoint", resp.Entrypoint})
|
||||||
|
}
|
||||||
|
if len(resp.Skills) > 0 {
|
||||||
|
for i, skill := range resp.Skills {
|
||||||
|
label := "skill"
|
||||||
|
if i > 0 {
|
||||||
|
label = ""
|
||||||
|
}
|
||||||
|
// Show skill name or digest
|
||||||
|
skillDisplay := skill.Name
|
||||||
|
if skillDisplay == "" && skill.Digest != "" {
|
||||||
|
skillDisplay = skill.Digest[:12] + "..."
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"", label, skillDisplay})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
if len(resp.MCPs) > 0 {
|
||||||
rows = append(rows, []string{"", "architecture", resp.Details.Family})
|
for i, mcp := range resp.MCPs {
|
||||||
rows = append(rows, []string{"", "parameters", resp.Details.ParameterSize})
|
label := "mcp"
|
||||||
}
|
if i > 0 {
|
||||||
rows = append(rows, []string{"", "quantization", resp.Details.QuantizationLevel})
|
label = ""
|
||||||
if resp.Requires != "" {
|
}
|
||||||
rows = append(rows, []string{"", "requires", resp.Requires})
|
// Show MCP name and command
|
||||||
}
|
mcpDisplay := mcp.Name
|
||||||
return
|
if mcp.Command != "" {
|
||||||
})
|
cmdLine := mcp.Command
|
||||||
|
if len(mcp.Args) > 0 {
|
||||||
|
cmdLine += " " + strings.Join(mcp.Args, " ")
|
||||||
|
}
|
||||||
|
mcpDisplay += " (" + cmdLine + ")"
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{"", label, mcpDisplay})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if len(resp.Capabilities) > 0 {
|
if len(resp.Capabilities) > 0 {
|
||||||
tableRender("Capabilities", func() (rows [][]string) {
|
tableRender("Capabilities", func() (rows [][]string) {
|
||||||
@@ -1198,6 +1315,11 @@ type runOptions struct {
|
|||||||
Think *api.ThinkValue
|
Think *api.ThinkValue
|
||||||
HideThinking bool
|
HideThinking bool
|
||||||
ShowConnect bool
|
ShowConnect bool
|
||||||
|
IsAgent bool
|
||||||
|
AgentType string
|
||||||
|
Skills []api.SkillRef
|
||||||
|
MCPs []api.MCPRef
|
||||||
|
Entrypoint string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r runOptions) Copy() runOptions {
|
func (r runOptions) Copy() runOptions {
|
||||||
@@ -1227,6 +1349,12 @@ func (r runOptions) Copy() runOptions {
|
|||||||
think = &cThink
|
think = &cThink
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var skills []api.SkillRef
|
||||||
|
if r.Skills != nil {
|
||||||
|
skills = make([]api.SkillRef, len(r.Skills))
|
||||||
|
copy(skills, r.Skills)
|
||||||
|
}
|
||||||
|
|
||||||
return runOptions{
|
return runOptions{
|
||||||
Model: r.Model,
|
Model: r.Model,
|
||||||
ParentModel: r.ParentModel,
|
ParentModel: r.ParentModel,
|
||||||
@@ -1242,6 +1370,9 @@ func (r runOptions) Copy() runOptions {
|
|||||||
Think: think,
|
Think: think,
|
||||||
HideThinking: r.HideThinking,
|
HideThinking: r.HideThinking,
|
||||||
ShowConnect: r.ShowConnect,
|
ShowConnect: r.ShowConnect,
|
||||||
|
IsAgent: r.IsAgent,
|
||||||
|
AgentType: r.AgentType,
|
||||||
|
Skills: skills,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,6 +1456,65 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load skills for agents
|
||||||
|
var skillsCatalog *skillCatalog
|
||||||
|
if opts.IsAgent && len(opts.Skills) > 0 {
|
||||||
|
skillsCatalog, err = loadSkillsFromRefs(opts.Skills)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load skills: %w", err)
|
||||||
|
}
|
||||||
|
if skillsCatalog != nil && len(skillsCatalog.Skills) > 0 {
|
||||||
|
var skillNames []string
|
||||||
|
for _, s := range skillsCatalog.Skills {
|
||||||
|
skillNames = append(skillNames, s.Name)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Loaded skills: %s\n", strings.Join(skillNames, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load MCP servers for agents (from opts and global config)
|
||||||
|
var mcpMgr *mcpManager
|
||||||
|
allMCPs := opts.MCPs
|
||||||
|
|
||||||
|
// Load global MCPs from ~/.ollama/mcp.json
|
||||||
|
if globalConfig, err := loadMCPConfig(); err == nil && len(globalConfig.MCPServers) > 0 {
|
||||||
|
for name, srv := range globalConfig.MCPServers {
|
||||||
|
// Skip disabled MCPs
|
||||||
|
if srv.Disabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Check if already in opts.MCPs (model takes precedence)
|
||||||
|
found := false
|
||||||
|
for _, m := range opts.MCPs {
|
||||||
|
if m.Name == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
allMCPs = append(allMCPs, api.MCPRef{
|
||||||
|
Name: name,
|
||||||
|
Command: srv.Command,
|
||||||
|
Args: srv.Args,
|
||||||
|
Env: srv.Env,
|
||||||
|
Type: srv.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allMCPs) > 0 {
|
||||||
|
mcpMgr = newMCPManager()
|
||||||
|
if err := mcpMgr.loadMCPsFromRefs(allMCPs); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load MCP servers: %w", err)
|
||||||
|
}
|
||||||
|
if mcpMgr.ToolCount() > 0 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Loaded MCP servers: %s (%d tools)\n",
|
||||||
|
strings.Join(mcpMgr.ServerNames(), ", "), mcpMgr.ToolCount())
|
||||||
|
}
|
||||||
|
defer mcpMgr.Shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
p := progress.NewProgress(os.Stderr)
|
p := progress.NewProgress(os.Stderr)
|
||||||
defer p.StopAndClear()
|
defer p.StopAndClear()
|
||||||
|
|
||||||
@@ -1348,6 +1538,7 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
var fullResponse strings.Builder
|
var fullResponse strings.Builder
|
||||||
var thinkTagOpened bool = false
|
var thinkTagOpened bool = false
|
||||||
var thinkTagClosed bool = false
|
var thinkTagClosed bool = false
|
||||||
|
var pendingToolCalls []api.ToolCall
|
||||||
|
|
||||||
role := "assistant"
|
role := "assistant"
|
||||||
|
|
||||||
@@ -1388,7 +1579,13 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
if response.Message.ToolCalls != nil {
|
if response.Message.ToolCalls != nil {
|
||||||
toolCalls := response.Message.ToolCalls
|
toolCalls := response.Message.ToolCalls
|
||||||
if len(toolCalls) > 0 {
|
if len(toolCalls) > 0 {
|
||||||
fmt.Print(renderToolCalls(toolCalls, false))
|
if skillsCatalog != nil || mcpMgr != nil {
|
||||||
|
// Store tool calls for execution after response is complete
|
||||||
|
pendingToolCalls = append(pendingToolCalls, toolCalls...)
|
||||||
|
} else {
|
||||||
|
// No skills catalog or MCP, just display tool calls
|
||||||
|
fmt.Print(renderToolCalls(toolCalls, false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1401,31 +1598,161 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) {
|
|||||||
opts.Format = `"` + opts.Format + `"`
|
opts.Format = `"` + opts.Format + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &api.ChatRequest{
|
// Prepare messages with agent-specific system prompt
|
||||||
Model: opts.Model,
|
messages := opts.Messages
|
||||||
Messages: opts.Messages,
|
if skillsCatalog != nil {
|
||||||
Format: json.RawMessage(opts.Format),
|
// Add skills system prompt as the first system message
|
||||||
Options: opts.Options,
|
skillsPrompt := skillsCatalog.SystemPrompt()
|
||||||
Think: opts.Think,
|
if skillsPrompt != "" {
|
||||||
|
// Insert skills prompt at the beginning, or append to existing system message
|
||||||
|
if len(messages) > 0 && messages[0].Role == "system" {
|
||||||
|
// Append to existing system message
|
||||||
|
messages[0].Content = messages[0].Content + "\n\n" + skillsPrompt
|
||||||
|
} else {
|
||||||
|
// Insert new system message at the beginning
|
||||||
|
systemMsg := api.Message{Role: "system", Content: skillsPrompt}
|
||||||
|
messages = append([]api.Message{systemMsg}, messages...)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.KeepAlive != nil {
|
// Agentic loop: continue until no more tool calls
|
||||||
req.KeepAlive = opts.KeepAlive
|
for {
|
||||||
}
|
req := &api.ChatRequest{
|
||||||
|
Model: opts.Model,
|
||||||
if err := client.Chat(cancelCtx, req, fn); err != nil {
|
Messages: messages,
|
||||||
if errors.Is(err, context.Canceled) {
|
Format: json.RawMessage(opts.Format),
|
||||||
return nil, nil
|
Options: opts.Options,
|
||||||
|
Think: opts.Think,
|
||||||
}
|
}
|
||||||
|
|
||||||
// this error should ideally be wrapped properly by the client
|
// Add tools for agents (combine skills and MCP tools)
|
||||||
if strings.Contains(err.Error(), "upstream error") {
|
var allTools api.Tools
|
||||||
p.StopAndClear()
|
if skillsCatalog != nil {
|
||||||
fmt.Println("An error occurred while processing your message. Please try again.")
|
allTools = append(allTools, skillsCatalog.Tools()...)
|
||||||
fmt.Println()
|
|
||||||
return nil, nil
|
|
||||||
}
|
}
|
||||||
return nil, err
|
if mcpMgr != nil {
|
||||||
|
allTools = append(allTools, mcpMgr.Tools()...)
|
||||||
|
}
|
||||||
|
if len(allTools) > 0 {
|
||||||
|
req.Tools = allTools
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.KeepAlive != nil {
|
||||||
|
req.KeepAlive = opts.KeepAlive
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Chat(cancelCtx, req, fn); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// this error should ideally be wrapped properly by the client
|
||||||
|
if strings.Contains(err.Error(), "upstream error") {
|
||||||
|
p.StopAndClear()
|
||||||
|
fmt.Println("An error occurred while processing your message. Please try again.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no tool calls, we're done
|
||||||
|
if len(pendingToolCalls) == 0 || (skillsCatalog == nil && mcpMgr == nil) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute tool calls and continue the conversation
|
||||||
|
fmt.Fprintf(os.Stderr, "\n")
|
||||||
|
|
||||||
|
// Add assistant's tool call message to history (include thinking for proper rendering)
|
||||||
|
assistantMsg := api.Message{
|
||||||
|
Role: "assistant",
|
||||||
|
Content: fullResponse.String(),
|
||||||
|
Thinking: thinkingContent.String(),
|
||||||
|
ToolCalls: pendingToolCalls,
|
||||||
|
}
|
||||||
|
messages = append(messages, assistantMsg)
|
||||||
|
|
||||||
|
// Execute each tool call and collect results
|
||||||
|
var toolResults []api.Message
|
||||||
|
for _, call := range pendingToolCalls {
|
||||||
|
// Show what's being executed
|
||||||
|
switch call.Function.Name {
|
||||||
|
case "run_skill_script":
|
||||||
|
skillVal, _ := call.Function.Arguments.Get("skill")
|
||||||
|
skill, _ := skillVal.(string)
|
||||||
|
commandVal, _ := call.Function.Arguments.Get("command")
|
||||||
|
command, _ := commandVal.(string)
|
||||||
|
fmt.Fprintf(os.Stderr, "Running script in %s: %s\n", skill, command)
|
||||||
|
case "read_skill_file":
|
||||||
|
skillVal, _ := call.Function.Arguments.Get("skill")
|
||||||
|
skill, _ := skillVal.(string)
|
||||||
|
pathVal, _ := call.Function.Arguments.Get("path")
|
||||||
|
path, _ := pathVal.(string)
|
||||||
|
fmt.Fprintf(os.Stderr, "Reading file from %s: %s\n", skill, path)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Executing: %s\n", call.Function.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result api.Message
|
||||||
|
var handled bool
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Try skill catalog first
|
||||||
|
if skillsCatalog != nil {
|
||||||
|
result, handled, err = skillsCatalog.RunToolCall(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not handled by skills, try MCP
|
||||||
|
if !handled && mcpMgr != nil {
|
||||||
|
result, handled, err = mcpMgr.RunToolCall(call)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
// Add error result
|
||||||
|
toolResults = append(toolResults, api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: fmt.Sprintf("Error: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: Unknown tool %s\n", call.Function.Name)
|
||||||
|
toolResults = append(toolResults, api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: fmt.Sprintf("Unknown tool: %s", call.Function.Name),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display tool output
|
||||||
|
if result.Content != "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Output:\n%s\n", result.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool result to messages (preserves ToolName, ToolCallID from result)
|
||||||
|
toolResults = append(toolResults, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add tool results to message history
|
||||||
|
messages = append(messages, toolResults...)
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n")
|
||||||
|
|
||||||
|
// Reset state for next iteration
|
||||||
|
fullResponse.Reset()
|
||||||
|
thinkingContent.Reset()
|
||||||
|
thinkTagOpened = false
|
||||||
|
thinkTagClosed = false
|
||||||
|
pendingToolCalls = nil
|
||||||
|
state = &displayResponseState{}
|
||||||
|
|
||||||
|
// Start new progress spinner for next API call
|
||||||
|
p = progress.NewProgress(os.Stderr)
|
||||||
|
spinner = progress.NewSpinner("")
|
||||||
|
p.Add("", spinner)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(opts.Messages) > 0 {
|
if len(opts.Messages) > 0 {
|
||||||
@@ -1918,6 +2245,8 @@ func NewCLI() *cobra.Command {
|
|||||||
copyCmd,
|
copyCmd,
|
||||||
deleteCmd,
|
deleteCmd,
|
||||||
runnerCmd,
|
runnerCmd,
|
||||||
|
NewSkillCommand(),
|
||||||
|
NewMCPCommand(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return rootCmd
|
return rootCmd
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
fmt.Fprintln(os.Stderr, "Available Commands:")
|
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||||
fmt.Fprintln(os.Stderr, " /set Set session variables")
|
fmt.Fprintln(os.Stderr, " /set Set session variables")
|
||||||
fmt.Fprintln(os.Stderr, " /show Show model information")
|
fmt.Fprintln(os.Stderr, " /show Show model information")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skills Show available skills")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill Add or remove skills dynamically")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp Show/add/remove MCP servers")
|
||||||
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
fmt.Fprintln(os.Stderr, " /load <model> Load a session or model")
|
||||||
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
fmt.Fprintln(os.Stderr, " /save <model> Save your current session")
|
||||||
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
fmt.Fprintln(os.Stderr, " /clear Clear session context")
|
||||||
@@ -444,6 +447,411 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
} else {
|
} else {
|
||||||
usageShow()
|
usageShow()
|
||||||
}
|
}
|
||||||
|
case strings.HasPrefix(line, "/skill "):
|
||||||
|
args := strings.Fields(line)
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill add <path> Add a skill from local path")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill remove <name> Remove a skill by name")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill list List current skills")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[1] {
|
||||||
|
case "add":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Println("Usage: /skill add <path>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillPath := args[2]
|
||||||
|
|
||||||
|
// Expand ~ to home directory
|
||||||
|
if strings.HasPrefix(skillPath, "~") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error expanding path: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillPath = filepath.Join(home, skillPath[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make absolute
|
||||||
|
absPath, err := filepath.Abs(skillPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error resolving path: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SKILL.md exists
|
||||||
|
skillMdPath := filepath.Join(absPath, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillMdPath); err != nil {
|
||||||
|
fmt.Printf("Error: %s does not contain SKILL.md\n", skillPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract skill name from SKILL.md
|
||||||
|
content, err := os.ReadFile(skillMdPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error reading SKILL.md: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillName, _ := extractSkillMetadata(string(content))
|
||||||
|
if skillName == "" {
|
||||||
|
skillName = filepath.Base(absPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already added
|
||||||
|
for _, s := range opts.Skills {
|
||||||
|
if s.Name == skillName {
|
||||||
|
fmt.Printf("Skill '%s' is already loaded\n", skillName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to skills (using path as Name, no digest for local skills)
|
||||||
|
opts.Skills = append(opts.Skills, api.SkillRef{Name: absPath})
|
||||||
|
opts.IsAgent = true // Enable agent mode if not already
|
||||||
|
fmt.Printf("Added skill '%s' from %s\n", skillName, skillPath)
|
||||||
|
|
||||||
|
case "remove", "rm":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Println("Usage: /skill remove <name>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skillName := args[2]
|
||||||
|
|
||||||
|
found := false
|
||||||
|
newSkills := make([]api.SkillRef, 0, len(opts.Skills))
|
||||||
|
for _, s := range opts.Skills {
|
||||||
|
// Match by name or by path basename
|
||||||
|
name := s.Name
|
||||||
|
if strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
name = filepath.Base(name)
|
||||||
|
}
|
||||||
|
if name == skillName || s.Name == skillName {
|
||||||
|
found = true
|
||||||
|
fmt.Printf("Removed skill '%s'\n", skillName)
|
||||||
|
} else {
|
||||||
|
newSkills = append(newSkills, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Printf("Skill '%s' not found\n", skillName)
|
||||||
|
} else {
|
||||||
|
opts.Skills = newSkills
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list", "ls":
|
||||||
|
if len(opts.Skills) == 0 {
|
||||||
|
fmt.Println("No skills loaded in this session.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Skills loaded in this session:")
|
||||||
|
for _, skill := range opts.Skills {
|
||||||
|
if skill.Digest != "" {
|
||||||
|
fmt.Printf(" %s (%s)\n", skill.Name, skill.Digest[:19])
|
||||||
|
} else {
|
||||||
|
// For local paths, show basename
|
||||||
|
name := skill.Name
|
||||||
|
if strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
name = filepath.Base(name) + " (local: " + skill.Name + ")"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s\n", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown skill command '%s'. Use /skill add, /skill remove, or /skill list\n", args[1])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
case strings.HasPrefix(line, "/skills"):
|
||||||
|
// Show skills from model (bundled) + session skills
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: couldn't connect to ollama server")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := &api.ShowRequest{
|
||||||
|
Name: opts.Model,
|
||||||
|
}
|
||||||
|
resp, err := client.Show(cmd.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: couldn't get model info")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine model skills with session skills
|
||||||
|
allSkills := make([]api.SkillRef, 0)
|
||||||
|
allSkills = append(allSkills, resp.Skills...)
|
||||||
|
|
||||||
|
// Add session skills that aren't already in model skills
|
||||||
|
for _, sessionSkill := range opts.Skills {
|
||||||
|
found := false
|
||||||
|
for _, modelSkill := range resp.Skills {
|
||||||
|
if modelSkill.Name == sessionSkill.Name || modelSkill.Digest == sessionSkill.Digest {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
allSkills = append(allSkills, sessionSkill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allSkills) == 0 {
|
||||||
|
fmt.Println("No skills available.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Available Skills:")
|
||||||
|
for _, skill := range allSkills {
|
||||||
|
if skill.Digest != "" {
|
||||||
|
fmt.Printf(" %s (%s)\n", skill.Name, skill.Digest[:19])
|
||||||
|
} else {
|
||||||
|
name := skill.Name
|
||||||
|
if strings.Contains(name, string(os.PathSeparator)) {
|
||||||
|
name = filepath.Base(name) + " (session)"
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s\n", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
continue
|
||||||
|
|
||||||
|
case strings.HasPrefix(line, "/mcp"):
|
||||||
|
args := strings.Fields(line)
|
||||||
|
|
||||||
|
// If just "/mcp" with no args, show all MCP servers
|
||||||
|
if len(args) == 1 {
|
||||||
|
// Show MCPs from model (bundled) + global config
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: couldn't connect to ollama server")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := &api.ShowRequest{
|
||||||
|
Name: opts.Model,
|
||||||
|
}
|
||||||
|
resp, err := client.Show(cmd.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("error: couldn't get model info")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine model MCPs with global config MCPs
|
||||||
|
allMCPs := make([]api.MCPRef, 0)
|
||||||
|
allMCPs = append(allMCPs, resp.MCPs...)
|
||||||
|
|
||||||
|
// Load global config
|
||||||
|
globalConfig, _ := loadMCPConfig()
|
||||||
|
globalMCPNames := make(map[string]bool)
|
||||||
|
|
||||||
|
if globalConfig != nil {
|
||||||
|
for name, srv := range globalConfig.MCPServers {
|
||||||
|
// Check if already in model MCPs
|
||||||
|
found := false
|
||||||
|
for _, modelMCP := range resp.MCPs {
|
||||||
|
if modelMCP.Name == name {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
allMCPs = append(allMCPs, api.MCPRef{
|
||||||
|
Name: name,
|
||||||
|
Command: srv.Command,
|
||||||
|
Args: srv.Args,
|
||||||
|
Env: srv.Env,
|
||||||
|
Type: srv.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
globalMCPNames[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allMCPs) == 0 {
|
||||||
|
fmt.Println("No MCP servers available.")
|
||||||
|
fmt.Println("Use '/mcp add <name> <command> [args...]' to add one.")
|
||||||
|
} else {
|
||||||
|
fmt.Println("Available MCP Servers:")
|
||||||
|
for _, mcp := range allMCPs {
|
||||||
|
cmdLine := mcp.Command
|
||||||
|
if len(mcp.Args) > 0 {
|
||||||
|
cmdLine += " " + strings.Join(mcp.Args, " ")
|
||||||
|
}
|
||||||
|
source := ""
|
||||||
|
disabled := ""
|
||||||
|
// Check if it's from model or global config
|
||||||
|
isFromModel := false
|
||||||
|
for _, modelMCP := range resp.MCPs {
|
||||||
|
if modelMCP.Name == mcp.Name {
|
||||||
|
isFromModel = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isFromModel {
|
||||||
|
source = " (model)"
|
||||||
|
} else if globalMCPNames[mcp.Name] {
|
||||||
|
source = " (global)"
|
||||||
|
// Check if disabled
|
||||||
|
if srv, ok := globalConfig.MCPServers[mcp.Name]; ok && srv.Disabled {
|
||||||
|
disabled = " [disabled]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s: %s%s%s\n", mcp.Name, cmdLine, source, disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[1] {
|
||||||
|
case "add":
|
||||||
|
if len(args) < 4 {
|
||||||
|
fmt.Println("Usage: /mcp add <name> <command> [args...]")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcpName := args[2]
|
||||||
|
mcpCommand := args[3]
|
||||||
|
mcpArgs := args[4:]
|
||||||
|
|
||||||
|
// Load global config
|
||||||
|
config, err := loadMCPConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if _, exists := config.MCPServers[mcpName]; exists {
|
||||||
|
fmt.Printf("Warning: overwriting existing MCP server '%s'\n", mcpName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to global config
|
||||||
|
config.MCPServers[mcpName] = MCPServerConfig{
|
||||||
|
Type: "stdio",
|
||||||
|
Command: mcpCommand,
|
||||||
|
Args: mcpArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
if err := saveMCPConfig(config); err != nil {
|
||||||
|
fmt.Printf("Error saving MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdLine := mcpCommand
|
||||||
|
if len(mcpArgs) > 0 {
|
||||||
|
cmdLine += " " + strings.Join(mcpArgs, " ")
|
||||||
|
}
|
||||||
|
fmt.Printf("Added MCP server '%s' (%s) to %s\n", mcpName, cmdLine, getMCPConfigPath())
|
||||||
|
fmt.Println("Note: MCP server will be started on next message.")
|
||||||
|
|
||||||
|
case "remove", "rm":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Println("Usage: /mcp remove <name>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcpName := args[2]
|
||||||
|
|
||||||
|
// Load global config
|
||||||
|
config, err := loadMCPConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := config.MCPServers[mcpName]; !exists {
|
||||||
|
fmt.Printf("MCP server '%s' not found in global config\n", mcpName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(config.MCPServers, mcpName)
|
||||||
|
|
||||||
|
if err := saveMCPConfig(config); err != nil {
|
||||||
|
fmt.Printf("Error saving MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Removed MCP server '%s' from %s\n", mcpName, getMCPConfigPath())
|
||||||
|
fmt.Println("Note: Changes will take effect on next message.")
|
||||||
|
|
||||||
|
case "disable":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Println("Usage: /mcp disable <name>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcpName := args[2]
|
||||||
|
|
||||||
|
config, err := loadMCPConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, exists := config.MCPServers[mcpName]
|
||||||
|
if !exists {
|
||||||
|
fmt.Printf("MCP server '%s' not found in global config\n", mcpName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.Disabled {
|
||||||
|
fmt.Printf("MCP server '%s' is already disabled\n", mcpName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.Disabled = true
|
||||||
|
config.MCPServers[mcpName] = srv
|
||||||
|
|
||||||
|
if err := saveMCPConfig(config); err != nil {
|
||||||
|
fmt.Printf("Error saving MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Disabled MCP server '%s'\n", mcpName)
|
||||||
|
fmt.Println("Note: Changes will take effect on next message.")
|
||||||
|
|
||||||
|
case "enable":
|
||||||
|
if len(args) < 3 {
|
||||||
|
fmt.Println("Usage: /mcp enable <name>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mcpName := args[2]
|
||||||
|
|
||||||
|
config, err := loadMCPConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, exists := config.MCPServers[mcpName]
|
||||||
|
if !exists {
|
||||||
|
fmt.Printf("MCP server '%s' not found in global config\n", mcpName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !srv.Disabled {
|
||||||
|
fmt.Printf("MCP server '%s' is already enabled\n", mcpName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.Disabled = false
|
||||||
|
config.MCPServers[mcpName] = srv
|
||||||
|
|
||||||
|
if err := saveMCPConfig(config); err != nil {
|
||||||
|
fmt.Printf("Error saving MCP config: %v\n", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Enabled MCP server '%s'\n", mcpName)
|
||||||
|
fmt.Println("Note: Changes will take effect on next message.")
|
||||||
|
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unknown mcp command '%s'. Use /mcp, /mcp add, /mcp remove, /mcp disable, or /mcp enable\n", args[1])
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"):
|
case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"):
|
||||||
args := strings.Fields(line)
|
args := strings.Fields(line)
|
||||||
if len(args) > 1 {
|
if len(args) > 1 {
|
||||||
@@ -452,6 +860,20 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error {
|
|||||||
usageSet()
|
usageSet()
|
||||||
case "show", "/show":
|
case "show", "/show":
|
||||||
usageShow()
|
usageShow()
|
||||||
|
case "skill", "/skill":
|
||||||
|
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill add <path> Add a skill from local path")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill remove <name> Remove a skill by name")
|
||||||
|
fmt.Fprintln(os.Stderr, " /skill list List current session skills")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
case "mcp", "/mcp":
|
||||||
|
fmt.Fprintln(os.Stderr, "Available Commands:")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp Show all MCP servers")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp add <name> <command> [args...] Add an MCP server to global config")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp remove <name> Remove an MCP server from global config")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp disable <name> Disable an MCP server (keep in config)")
|
||||||
|
fmt.Fprintln(os.Stderr, " /mcp enable <name> Re-enable a disabled MCP server")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
case "shortcut", "shortcuts":
|
case "shortcut", "shortcuts":
|
||||||
usageShortcuts()
|
usageShortcuts()
|
||||||
}
|
}
|
||||||
|
|||||||
570
cmd/skill_cmd.go
Normal file
570
cmd/skill_cmd.go
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/format"
|
||||||
|
"github.com/ollama/ollama/progress"
|
||||||
|
"github.com/ollama/ollama/server"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SkillPushHandler handles the skill push command.
|
||||||
|
func SkillPushHandler(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return fmt.Errorf("usage: ollama skill push NAME[:TAG] PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
path := args[1]
|
||||||
|
|
||||||
|
// Expand path
|
||||||
|
if strings.HasPrefix(path, "~") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expanding home directory: %w", err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(home, path[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate skill directory
|
||||||
|
skillMdPath := filepath.Join(absPath, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillMdPath); err != nil {
|
||||||
|
return fmt.Errorf("skill directory must contain SKILL.md: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse skill name (will set Kind="skill")
|
||||||
|
n := server.ParseSkillName(name)
|
||||||
|
if n.Model == "" {
|
||||||
|
return fmt.Errorf("invalid skill name: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := progress.NewProgress(os.Stderr)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
// Create skill layer
|
||||||
|
displayName := n.DisplayShortest()
|
||||||
|
status := fmt.Sprintf("Creating skill layer for %s", displayName)
|
||||||
|
spinner := progress.NewSpinner(status)
|
||||||
|
p.Add(status, spinner)
|
||||||
|
|
||||||
|
layer, err := server.CreateSkillLayer(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating skill layer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
spinner.Stop()
|
||||||
|
|
||||||
|
// Create skill manifest
|
||||||
|
manifest, configLayer, err := createSkillManifest(absPath, layer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating skill manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write manifest locally
|
||||||
|
manifestPath, err := server.GetSkillManifestPath(n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting manifest path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("creating manifest directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifestJSON, err := json.Marshal(manifest)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshaling manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(manifestPath, manifestJSON, 0o644); err != nil {
|
||||||
|
return fmt.Errorf("writing manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Skill %s created locally\n", displayName)
|
||||||
|
fmt.Fprintf(os.Stderr, " Config: %s (%s)\n", configLayer.Digest, format.HumanBytes(configLayer.Size))
|
||||||
|
fmt.Fprintf(os.Stderr, " Layer: %s (%s)\n", layer.Digest, format.HumanBytes(layer.Size))
|
||||||
|
|
||||||
|
// Push to registry
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insecure, _ := cmd.Flags().GetBool("insecure")
|
||||||
|
|
||||||
|
// For now, we'll use the existing push mechanism
|
||||||
|
fmt.Fprintf(os.Stderr, "\nPushing to registry...\n")
|
||||||
|
|
||||||
|
fn := func(resp api.ProgressResponse) error {
|
||||||
|
if resp.Digest != "" {
|
||||||
|
bar := progress.NewBar(resp.Status, resp.Total, resp.Completed)
|
||||||
|
p.Add(resp.Digest, bar)
|
||||||
|
} else if resp.Status != "" {
|
||||||
|
spinner := progress.NewSpinner(resp.Status)
|
||||||
|
p.Add(resp.Status, spinner)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &api.PushRequest{
|
||||||
|
Model: displayName,
|
||||||
|
Insecure: insecure,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Push(context.Background(), req, fn); err != nil {
|
||||||
|
// If push fails, still show success for local creation
|
||||||
|
fmt.Fprintf(os.Stderr, "\nNote: Local skill created but push failed: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, "You can try pushing later with: ollama skill push %s\n", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Successfully pushed %s\n", displayName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillPullHandler handles the skill pull command.
|
||||||
|
func SkillPullHandler(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("usage: ollama skill pull NAME[:TAG]")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
n := server.ParseSkillName(name)
|
||||||
|
if n.Model == "" {
|
||||||
|
return fmt.Errorf("invalid skill name: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := api.ClientFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
insecure, _ := cmd.Flags().GetBool("insecure")
|
||||||
|
|
||||||
|
p := progress.NewProgress(os.Stderr)
|
||||||
|
defer p.Stop()
|
||||||
|
|
||||||
|
fn := func(resp api.ProgressResponse) error {
|
||||||
|
if resp.Digest != "" {
|
||||||
|
bar := progress.NewBar(resp.Status, resp.Total, resp.Completed)
|
||||||
|
p.Add(resp.Digest, bar)
|
||||||
|
} else if resp.Status != "" {
|
||||||
|
spinner := progress.NewSpinner(resp.Status)
|
||||||
|
p.Add(resp.Status, spinner)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := n.DisplayShortest()
|
||||||
|
req := &api.PullRequest{
|
||||||
|
Model: displayName,
|
||||||
|
Insecure: insecure,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := client.Pull(context.Background(), req, fn); err != nil {
|
||||||
|
return fmt.Errorf("pulling skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Successfully pulled %s\n", displayName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillListHandler handles the skill list command.
|
||||||
|
func SkillListHandler(cmd *cobra.Command, args []string) error {
|
||||||
|
skills, err := listLocalSkills()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing skills: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skills) == 0 {
|
||||||
|
fmt.Println("No skills installed")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "NAME\tTAG\tSIZE\tMODIFIED")
|
||||||
|
|
||||||
|
for _, skill := range skills {
|
||||||
|
fmt.Fprintf(w, "%s/%s\t%s\t%s\t%s\n",
|
||||||
|
skill.Namespace,
|
||||||
|
skill.Name,
|
||||||
|
skill.Tag,
|
||||||
|
format.HumanBytes(skill.Size),
|
||||||
|
format.HumanTime(skill.ModifiedAt, "Never"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillRemoveHandler handles the skill rm command.
|
||||||
|
func SkillRemoveHandler(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("usage: ollama skill rm NAME[:TAG] [NAME[:TAG]...]")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range args {
|
||||||
|
n := server.ParseSkillName(name)
|
||||||
|
if n.Model == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "Invalid skill name: %s\n", name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := n.DisplayShortest()
|
||||||
|
manifestPath, err := server.GetSkillManifestPath(n)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error getting manifest path for %s: %v\n", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Skill not found: %s\n", displayName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(manifestPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error removing %s: %v\n", displayName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up empty parent directories
|
||||||
|
dir := filepath.Dir(manifestPath)
|
||||||
|
for dir != filepath.Join(os.Getenv("HOME"), ".ollama", "models", "manifests") {
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
if len(entries) == 0 {
|
||||||
|
os.Remove(dir)
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Deleted '%s'\n", displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillShowHandler handles the skill show command.
|
||||||
|
func SkillShowHandler(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("usage: ollama skill show NAME[:TAG]")
|
||||||
|
}
|
||||||
|
|
||||||
|
name := args[0]
|
||||||
|
n := server.ParseSkillName(name)
|
||||||
|
if n.Model == "" {
|
||||||
|
return fmt.Errorf("invalid skill name: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayName := n.DisplayShortest()
|
||||||
|
manifestPath, err := server.GetSkillManifestPath(n)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting manifest path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("skill not found: %s", displayName)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("reading manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest server.Manifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
return fmt.Errorf("parsing manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Skill: %s\n\n", displayName)
|
||||||
|
|
||||||
|
fmt.Println("Layers:")
|
||||||
|
for _, layer := range manifest.Layers {
|
||||||
|
fmt.Printf(" %s %s %s\n", layer.MediaType, layer.Digest[:19], format.HumanBytes(layer.Size))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to read and display SKILL.md content
|
||||||
|
if len(manifest.Layers) > 0 {
|
||||||
|
for _, layer := range manifest.Layers {
|
||||||
|
if layer.MediaType == server.MediaTypeSkill {
|
||||||
|
skillPath, err := server.GetSkillsPath(layer.Digest)
|
||||||
|
if err == nil {
|
||||||
|
skillMdPath := filepath.Join(skillPath, "SKILL.md")
|
||||||
|
if content, err := os.ReadFile(skillMdPath); err == nil {
|
||||||
|
fmt.Println("\nContent:")
|
||||||
|
fmt.Println(string(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillInfo represents information about an installed skill.
|
||||||
|
type SkillInfo struct {
|
||||||
|
Namespace string
|
||||||
|
Name string
|
||||||
|
Tag string
|
||||||
|
Size int64
|
||||||
|
ModifiedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// listLocalSkills returns a list of locally installed skills.
|
||||||
|
// Skills are stored with 5-part paths: host/namespace/kind/model/tag
|
||||||
|
// where kind is "skill".
|
||||||
|
func listLocalSkills() ([]SkillInfo, error) {
|
||||||
|
manifestsPath := filepath.Join(os.Getenv("HOME"), ".ollama", "models", "manifests")
|
||||||
|
|
||||||
|
var skills []SkillInfo
|
||||||
|
|
||||||
|
// Walk through all registries
|
||||||
|
registries, err := os.ReadDir(manifestsPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return skills, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, registry := range registries {
|
||||||
|
if !registry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk namespaces
|
||||||
|
namespaces, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, namespace := range namespaces {
|
||||||
|
if !namespace.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk kinds looking for "skill"
|
||||||
|
kinds, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, kind := range kinds {
|
||||||
|
if !kind.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process skill kind
|
||||||
|
if kind.Name() != server.SkillNamespace {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk skill names (model names)
|
||||||
|
skillNames, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, skillName := range skillNames {
|
||||||
|
if !skillName.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk tags
|
||||||
|
tags, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name(), skillName.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tag := range tags {
|
||||||
|
manifestPath := filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name(), skillName.Name(), tag.Name())
|
||||||
|
fi, err := os.Stat(manifestPath)
|
||||||
|
if err != nil || fi.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read manifest to get size
|
||||||
|
data, err := os.ReadFile(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest server.Manifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalSize int64
|
||||||
|
for _, layer := range manifest.Layers {
|
||||||
|
totalSize += layer.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build display name using model.Name
|
||||||
|
n := model.Name{
|
||||||
|
Host: registry.Name(),
|
||||||
|
Namespace: namespace.Name(),
|
||||||
|
Kind: kind.Name(),
|
||||||
|
Model: skillName.Name(),
|
||||||
|
Tag: tag.Name(),
|
||||||
|
}
|
||||||
|
|
||||||
|
skills = append(skills, SkillInfo{
|
||||||
|
Namespace: n.Namespace + "/" + n.Kind,
|
||||||
|
Name: n.Model,
|
||||||
|
Tag: n.Tag,
|
||||||
|
Size: totalSize,
|
||||||
|
ModifiedAt: fi.ModTime(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSkillManifest creates a manifest for a standalone skill.
|
||||||
|
func createSkillManifest(skillDir string, layer server.Layer) (*server.Manifest, *server.Layer, error) {
|
||||||
|
// Read SKILL.md to extract metadata
|
||||||
|
skillMdPath := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
content, err := os.ReadFile(skillMdPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("reading SKILL.md: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract name and description from frontmatter
|
||||||
|
name, description := extractSkillMetadata(string(content))
|
||||||
|
if name == "" {
|
||||||
|
return nil, nil, errors.New("skill name not found in SKILL.md frontmatter")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
config := map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"architecture": "amd64",
|
||||||
|
"os": "linux",
|
||||||
|
}
|
||||||
|
|
||||||
|
configJSON, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("marshaling config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create config layer
|
||||||
|
configLayer, err := server.NewLayer(strings.NewReader(string(configJSON)), "application/vnd.docker.container.image.v1+json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating config layer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest := &server.Manifest{
|
||||||
|
SchemaVersion: 2,
|
||||||
|
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
Config: configLayer,
|
||||||
|
Layers: []server.Layer{layer},
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest, &configLayer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSkillMetadata extracts name and description from SKILL.md frontmatter.
|
||||||
|
func extractSkillMetadata(content string) (name, description string) {
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
|
||||||
|
inFrontmatter := false
|
||||||
|
for _, line := range lines {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
|
||||||
|
if trimmed == "---" {
|
||||||
|
if !inFrontmatter {
|
||||||
|
inFrontmatter = true
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
break // End of frontmatter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inFrontmatter {
|
||||||
|
if strings.HasPrefix(trimmed, "name:") {
|
||||||
|
name = strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
|
||||||
|
} else if strings.HasPrefix(trimmed, "description:") {
|
||||||
|
description = strings.TrimSpace(strings.TrimPrefix(trimmed, "description:"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return name, description
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSkillCommand creates the skill parent command with subcommands.
|
||||||
|
func NewSkillCommand() *cobra.Command {
|
||||||
|
skillCmd := &cobra.Command{
|
||||||
|
Use: "skill",
|
||||||
|
Short: "Manage skills",
|
||||||
|
Long: "Commands for managing agent skills (push, pull, list, rm, show)",
|
||||||
|
}
|
||||||
|
|
||||||
|
pushCmd := &cobra.Command{
|
||||||
|
Use: "push NAME[:TAG] PATH",
|
||||||
|
Short: "Push a skill to a registry",
|
||||||
|
Long: "Package a local skill directory and push it to a registry",
|
||||||
|
Args: cobra.ExactArgs(2),
|
||||||
|
PreRunE: checkServerHeartbeat,
|
||||||
|
RunE: SkillPushHandler,
|
||||||
|
}
|
||||||
|
pushCmd.Flags().Bool("insecure", false, "Use an insecure registry")
|
||||||
|
|
||||||
|
pullCmd := &cobra.Command{
|
||||||
|
Use: "pull NAME[:TAG]",
|
||||||
|
Short: "Pull a skill from a registry",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
PreRunE: checkServerHeartbeat,
|
||||||
|
RunE: SkillPullHandler,
|
||||||
|
}
|
||||||
|
pullCmd.Flags().Bool("insecure", false, "Use an insecure registry")
|
||||||
|
|
||||||
|
listCmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Short: "List installed skills",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: SkillListHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
rmCmd := &cobra.Command{
|
||||||
|
Use: "rm NAME[:TAG] [NAME[:TAG]...]",
|
||||||
|
Aliases: []string{"remove", "delete"},
|
||||||
|
Short: "Remove a skill",
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: SkillRemoveHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
showCmd := &cobra.Command{
|
||||||
|
Use: "show NAME[:TAG]",
|
||||||
|
Short: "Show skill details",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: SkillShowHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
skillCmd.AddCommand(pushCmd, pullCmd, listCmd, rmCmd, showCmd)
|
||||||
|
|
||||||
|
return skillCmd
|
||||||
|
}
|
||||||
591
cmd/skills.go
Normal file
591
cmd/skills.go
Normal file
@@ -0,0 +1,591 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
skillFileName = "SKILL.md"
|
||||||
|
maxSkillDescription = 1024
|
||||||
|
maxSkillNameLength = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
|
type skillMetadata struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillDefinition struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Content string // Full SKILL.md content (without frontmatter)
|
||||||
|
Dir string
|
||||||
|
SkillPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillCatalog struct {
|
||||||
|
Skills []skillDefinition
|
||||||
|
byName map[string]skillDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSkills(paths []string) (*skillCatalog, error) {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var skills []skillDefinition
|
||||||
|
byName := make(map[string]skillDefinition)
|
||||||
|
for _, root := range paths {
|
||||||
|
info, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("skills directory %q: %w", root, err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, fmt.Errorf("skills path %q is not a directory", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.Name() != skillFileName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Dir(path)
|
||||||
|
skill, err := parseSkillFile(path, skillDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: skipping skill at %s: %v\n", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := byName[skill.Name]; exists {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q at %s\n", skill.Name, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byName[skill.Name] = skill
|
||||||
|
skills = append(skills, skill)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skills) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(skills, func(i, j int) bool {
|
||||||
|
return skills[i].Name < skills[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return &skillCatalog{Skills: skills, byName: byName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadSkillsFromRefs loads skills from a list of SkillRef objects.
|
||||||
|
// Skills can be referenced by:
|
||||||
|
// - Digest: loaded from the extracted skill cache (for bundled/pulled skills)
|
||||||
|
// - Name (local path): loaded from the filesystem (for development)
|
||||||
|
func loadSkillsFromRefs(refs []api.SkillRef) (*skillCatalog, error) {
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var skills []skillDefinition
|
||||||
|
byName := make(map[string]skillDefinition)
|
||||||
|
|
||||||
|
for _, ref := range refs {
|
||||||
|
var skillDir string
|
||||||
|
|
||||||
|
if ref.Digest != "" {
|
||||||
|
// Load from extracted skill cache
|
||||||
|
path, err := server.GetSkillsPath(ref.Digest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("getting skill path for %s: %w", ref.Digest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if skill is already extracted
|
||||||
|
skillMdPath := filepath.Join(path, skillFileName)
|
||||||
|
if _, err := os.Stat(skillMdPath); os.IsNotExist(err) {
|
||||||
|
// Try to extract the skill blob
|
||||||
|
path, err = server.ExtractSkillBlob(ref.Digest)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("extracting skill %s: %w", ref.Digest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir = path
|
||||||
|
} else if ref.Name != "" {
|
||||||
|
// Check if this is a local path or a registry reference
|
||||||
|
if !server.IsLocalSkillPath(ref.Name) {
|
||||||
|
// Registry reference without a digest - skill needs to be pulled first
|
||||||
|
// This happens when an agent references a skill that hasn't been bundled
|
||||||
|
return nil, fmt.Errorf("skill %q is a registry reference but has no digest - the agent may need to be recreated or the skill pulled separately", ref.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local path - resolve it
|
||||||
|
skillPath := ref.Name
|
||||||
|
if strings.HasPrefix(skillPath, "~") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("expanding home directory: %w", err)
|
||||||
|
}
|
||||||
|
skillPath = filepath.Join(home, skillPath[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(skillPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("resolving skill path %q: %w", ref.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a directory containing skills or a single skill
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("skill path %q: %w", ref.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
// Check if it's a skill directory (has SKILL.md) or a parent of skill directories
|
||||||
|
skillMdPath := filepath.Join(absPath, skillFileName)
|
||||||
|
if _, err := os.Stat(skillMdPath); err == nil {
|
||||||
|
// Direct skill directory
|
||||||
|
skillDir = absPath
|
||||||
|
} else {
|
||||||
|
// Parent directory - walk to find skill subdirectories
|
||||||
|
err := filepath.WalkDir(absPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.Name() != skillFileName {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
skillSubDir := filepath.Dir(path)
|
||||||
|
skill, err := parseSkillFile(path, skillSubDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: skipping skill at %s: %v\n", path, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := byName[skill.Name]; exists {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q at %s\n", skill.Name, path)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
byName[skill.Name] = skill
|
||||||
|
skills = append(skills, skill)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("skill path %q is not a directory", ref.Name)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Both empty - skip
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the skill from skillDir if set
|
||||||
|
if skillDir != "" {
|
||||||
|
skillMdPath := filepath.Join(skillDir, skillFileName)
|
||||||
|
skill, err := parseSkillFile(skillMdPath, skillDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing skill at %s: %w", skillDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, exists := byName[skill.Name]; exists {
|
||||||
|
fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q\n", skill.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
byName[skill.Name] = skill
|
||||||
|
skills = append(skills, skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(skills) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(skills, func(i, j int) bool {
|
||||||
|
return skills[i].Name < skills[j].Name
|
||||||
|
})
|
||||||
|
|
||||||
|
return &skillCatalog{Skills: skills, byName: byName}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSkillFile(path, skillDir string) (skillDefinition, error) {
|
||||||
|
rawContent, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return skillDefinition{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
frontmatter, bodyContent, err := extractFrontmatterAndContent(string(rawContent))
|
||||||
|
if err != nil {
|
||||||
|
return skillDefinition{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta skillMetadata
|
||||||
|
if err := yaml.Unmarshal([]byte(frontmatter), &meta); err != nil {
|
||||||
|
return skillDefinition{}, fmt.Errorf("invalid frontmatter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateSkillMetadata(meta, skillDir); err != nil {
|
||||||
|
return skillDefinition{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return skillDefinition{}, err
|
||||||
|
}
|
||||||
|
absDir, err := filepath.Abs(skillDir)
|
||||||
|
if err != nil {
|
||||||
|
return skillDefinition{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillDefinition{
|
||||||
|
Name: meta.Name,
|
||||||
|
Description: meta.Description,
|
||||||
|
Content: bodyContent,
|
||||||
|
Dir: absDir,
|
||||||
|
SkillPath: absPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractFrontmatterAndContent(content string) (frontmatter string, body string, err error) {
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(content))
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return "", "", errors.New("empty SKILL.md")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(scanner.Text()) != "---" {
|
||||||
|
return "", "", errors.New("missing YAML frontmatter")
|
||||||
|
}
|
||||||
|
|
||||||
|
var fmLines []string
|
||||||
|
foundEnd := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.TrimSpace(line) == "---" {
|
||||||
|
foundEnd = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmLines = append(fmLines, line)
|
||||||
|
}
|
||||||
|
if !foundEnd {
|
||||||
|
return "", "", errors.New("frontmatter not terminated")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect remaining content as body
|
||||||
|
var bodyLines []string
|
||||||
|
for scanner.Scan() {
|
||||||
|
bodyLines = append(bodyLines, scanner.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(fmLines, "\n"), strings.TrimSpace(strings.Join(bodyLines, "\n")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSkillMetadata(meta skillMetadata, skillDir string) error {
|
||||||
|
name := strings.TrimSpace(meta.Name)
|
||||||
|
description := strings.TrimSpace(meta.Description)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case name == "":
|
||||||
|
return errors.New("missing skill name")
|
||||||
|
case len(name) > maxSkillNameLength:
|
||||||
|
return fmt.Errorf("skill name exceeds %d characters", maxSkillNameLength)
|
||||||
|
case !skillNamePattern.MatchString(name):
|
||||||
|
return fmt.Errorf("invalid skill name %q", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if description == "" {
|
||||||
|
return errors.New("missing skill description")
|
||||||
|
}
|
||||||
|
if len(description) > maxSkillDescription {
|
||||||
|
return fmt.Errorf("skill description exceeds %d characters", maxSkillDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directory name check for digest-based paths (extracted from blobs)
|
||||||
|
dirName := filepath.Base(skillDir)
|
||||||
|
if !strings.HasPrefix(dirName, "sha256-") && dirName != name {
|
||||||
|
return fmt.Errorf("skill directory %q does not match name %q", dirName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *skillCatalog) SystemPrompt() string {
|
||||||
|
if c == nil || len(c.Skills) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("# Skills\n\n")
|
||||||
|
b.WriteString("You have the following skills loaded. Each skill provides instructions and may include executable scripts.\n\n")
|
||||||
|
b.WriteString("## Available Tools\n\n")
|
||||||
|
b.WriteString("- `run_skill_script`: Execute a script bundled with a skill. Use this when the skill instructions tell you to run a script.\n")
|
||||||
|
b.WriteString("- `read_skill_file`: Read additional files from a skill directory.\n\n")
|
||||||
|
|
||||||
|
for _, skill := range c.Skills {
|
||||||
|
fmt.Fprintf(&b, "## Skill: %s\n\n", skill.Name)
|
||||||
|
fmt.Fprintf(&b, "%s\n\n", skill.Content)
|
||||||
|
b.WriteString("---\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *skillCatalog) Tools() api.Tools {
|
||||||
|
if c == nil || len(c.Skills) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
runScriptProps := api.NewToolPropertiesMap()
|
||||||
|
runScriptProps.Set("skill", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "The name of the skill containing the script",
|
||||||
|
})
|
||||||
|
runScriptProps.Set("command", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "The command to execute (e.g., 'python scripts/calculate.py 25 4' or './scripts/run.sh')",
|
||||||
|
})
|
||||||
|
|
||||||
|
readFileProps := api.NewToolPropertiesMap()
|
||||||
|
readFileProps.Set("skill", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "The name of the skill containing the file",
|
||||||
|
})
|
||||||
|
readFileProps.Set("path", api.ToolProperty{
|
||||||
|
Type: api.PropertyType{"string"},
|
||||||
|
Description: "The relative path to the file within the skill directory",
|
||||||
|
})
|
||||||
|
|
||||||
|
return api.Tools{
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: api.ToolFunction{
|
||||||
|
Name: "run_skill_script",
|
||||||
|
Description: "Execute a script or command within a skill's directory. Use this to run Python scripts, shell scripts, or other executables bundled with a skill.",
|
||||||
|
Parameters: api.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Required: []string{"skill", "command"},
|
||||||
|
Properties: runScriptProps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "function",
|
||||||
|
Function: api.ToolFunction{
|
||||||
|
Name: "read_skill_file",
|
||||||
|
Description: "Read a file from a skill's directory. Use this to read additional documentation, reference files, or data files bundled with a skill.",
|
||||||
|
Parameters: api.ToolFunctionParameters{
|
||||||
|
Type: "object",
|
||||||
|
Required: []string{"skill", "path"},
|
||||||
|
Properties: readFileProps,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *skillCatalog) RunToolCall(call api.ToolCall) (api.Message, bool, error) {
|
||||||
|
switch call.Function.Name {
|
||||||
|
case "read_skill_file":
|
||||||
|
skillName, err := requireStringArg(call.Function.Arguments, "skill")
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, err.Error()), true, nil
|
||||||
|
}
|
||||||
|
relPath, err := requireStringArg(call.Function.Arguments, "path")
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, err.Error()), true, nil
|
||||||
|
}
|
||||||
|
skill, ok := c.byName[skillName]
|
||||||
|
if !ok {
|
||||||
|
return toolMessage(call, fmt.Sprintf("unknown skill %q", skillName)), true, nil
|
||||||
|
}
|
||||||
|
content, err := readSkillFile(skill.Dir, relPath)
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, err.Error()), true, nil
|
||||||
|
}
|
||||||
|
return toolMessage(call, content), true, nil
|
||||||
|
|
||||||
|
case "run_skill_script":
|
||||||
|
skillName, err := requireStringArg(call.Function.Arguments, "skill")
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, err.Error()), true, nil
|
||||||
|
}
|
||||||
|
command, err := requireStringArg(call.Function.Arguments, "command")
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, err.Error()), true, nil
|
||||||
|
}
|
||||||
|
skill, ok := c.byName[skillName]
|
||||||
|
if !ok {
|
||||||
|
return toolMessage(call, fmt.Sprintf("unknown skill %q", skillName)), true, nil
|
||||||
|
}
|
||||||
|
output, err := runSkillScript(skill.Dir, command)
|
||||||
|
if err != nil {
|
||||||
|
return toolMessage(call, fmt.Sprintf("error: %v\noutput: %s", err, output)), true, nil
|
||||||
|
}
|
||||||
|
return toolMessage(call, output), true, nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return api.Message{}, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runSkillScript executes a shell command within a skill's directory.
|
||||||
|
//
|
||||||
|
// SECURITY LIMITATIONS (TODO):
|
||||||
|
// - No sandboxing: commands run with full user permissions
|
||||||
|
// - No path validation: model can run any command, not just scripts in skill dir
|
||||||
|
// - Shell injection risk: sh -c is used, malicious input could be crafted
|
||||||
|
// - No executable allowlist: any program can be called (curl, rm, etc.)
|
||||||
|
// - No environment isolation: scripts inherit full environment variables
|
||||||
|
//
|
||||||
|
// POTENTIAL IMPROVEMENTS:
|
||||||
|
// - Restrict commands to only reference files within skill directory
|
||||||
|
// - Allowlist specific executables (python3, node, bash)
|
||||||
|
// - Use sandboxing (Docker, nsjail, seccomp)
|
||||||
|
// - Require explicit script registration in SKILL.md frontmatter
|
||||||
|
// - Add per-skill configurable timeouts
|
||||||
|
func runSkillScript(skillDir, command string) (string, error) {
|
||||||
|
// Validate the skill directory exists
|
||||||
|
absSkillDir, err := filepath.Abs(skillDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(absSkillDir); err != nil {
|
||||||
|
return "", fmt.Errorf("skill directory not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command with timeout
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||||
|
cmd.Dir = absSkillDir
|
||||||
|
|
||||||
|
// Inject the current working directory (where ollama run was called from)
|
||||||
|
// as an environment variable so scripts can reference files in that directory
|
||||||
|
workingDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get working directory: %w", err)
|
||||||
|
}
|
||||||
|
cmd.Env = append(os.Environ(), "OLLAMA_WORKING_DIR="+workingDir)
|
||||||
|
|
||||||
|
// Capture both stdout and stderr
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err = cmd.Run()
|
||||||
|
|
||||||
|
// Combine output
|
||||||
|
output := stdout.String()
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
if output != "" {
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
output += stderr.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return output, fmt.Errorf("command timed out after 30 seconds")
|
||||||
|
}
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSkillFile(skillDir, relPath string) (string, error) {
|
||||||
|
relPath = filepath.Clean(strings.TrimSpace(relPath))
|
||||||
|
if relPath == "" {
|
||||||
|
return "", errors.New("path is required")
|
||||||
|
}
|
||||||
|
if filepath.IsAbs(relPath) {
|
||||||
|
return "", errors.New("path must be relative to the skill directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(skillDir, relPath)
|
||||||
|
absTarget, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
absSkillDir, err := filepath.Abs(skillDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absSkillDir, absTarget)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(rel, "..") {
|
||||||
|
return "", errors.New("path escapes the skill directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(absTarget)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read %q: %w", relPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireStringArg(args api.ToolCallFunctionArguments, name string) (string, error) {
|
||||||
|
value, ok := args.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("missing required argument %q", name)
|
||||||
|
}
|
||||||
|
str, ok := value.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("argument %q must be a string", name)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(str) == "" {
|
||||||
|
return "", fmt.Errorf("argument %q cannot be empty", name)
|
||||||
|
}
|
||||||
|
return str, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolMessage(call api.ToolCall, content string) api.Message {
|
||||||
|
msg := api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: content,
|
||||||
|
ToolName: call.Function.Name,
|
||||||
|
}
|
||||||
|
if call.ID != "" {
|
||||||
|
msg.ToolCallID = call.ID
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
548
docs/skills.md
Normal file
548
docs/skills.md
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
# Ollama Skills
|
||||||
|
|
||||||
|
Skills are reusable capability packages that extend what agents can do. They bundle instructions, scripts, and data that teach an agent how to perform specific tasks.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Creating a Skill
|
||||||
|
|
||||||
|
Create a directory with a `SKILL.md` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
my-skill/
|
||||||
|
├── SKILL.md # Required: Instructions for the agent
|
||||||
|
└── scripts/ # Optional: Executable scripts
|
||||||
|
└── run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The `SKILL.md` file must have YAML frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
description: A brief description of what this skill does
|
||||||
|
---
|
||||||
|
|
||||||
|
# My Skill
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
Explain what this skill does and when to use it.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
Step-by-step instructions for the agent on how to use this skill.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
Show example inputs and expected outputs.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Skills in an Agent
|
||||||
|
|
||||||
|
Reference skills in your Agentfile:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM llama3.2:3b
|
||||||
|
AGENT_TYPE conversational
|
||||||
|
|
||||||
|
# Local skill (bundled with agent)
|
||||||
|
SKILL ./path/to/my-skill
|
||||||
|
|
||||||
|
# Registry skill (pulled from ollama.com)
|
||||||
|
SKILL library/skill/calculator:1.0.0
|
||||||
|
|
||||||
|
# User skill from registry
|
||||||
|
SKILL myname/skill/calculator:1.0.0
|
||||||
|
|
||||||
|
SYSTEM You are a helpful assistant.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing Skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Push a skill to the registry (uses your namespace)
|
||||||
|
ollama skill push myname/skill/calculator:1.0.0 ./my-skill
|
||||||
|
|
||||||
|
# Pull a skill from the official library
|
||||||
|
ollama skill pull skill/calculator:1.0.0
|
||||||
|
|
||||||
|
# Pull a skill from a user's namespace
|
||||||
|
ollama skill pull myname/skill/calculator:1.0.0
|
||||||
|
|
||||||
|
# List installed skills
|
||||||
|
ollama skill list
|
||||||
|
|
||||||
|
# Show skill details
|
||||||
|
ollama skill show skill/calculator:1.0.0
|
||||||
|
|
||||||
|
# Remove a skill
|
||||||
|
ollama skill rm skill/calculator:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Skills in Chat
|
||||||
|
|
||||||
|
You can add and remove skills dynamically during an interactive chat session:
|
||||||
|
|
||||||
|
```
|
||||||
|
>>> /skills
|
||||||
|
Available Skills:
|
||||||
|
calculator (sha256:abc123def456...)
|
||||||
|
|
||||||
|
>>> /skill add ./my-local-skill
|
||||||
|
Added skill 'my-skill' from ./my-local-skill
|
||||||
|
|
||||||
|
>>> /skill list
|
||||||
|
Skills loaded in this session:
|
||||||
|
my-skill (local: /path/to/my-local-skill)
|
||||||
|
|
||||||
|
>>> /skill remove my-skill
|
||||||
|
Removed skill 'my-skill'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/skills` | Show all available skills (model + session) |
|
||||||
|
| `/skill add <path>` | Add a skill from a local path |
|
||||||
|
| `/skill remove <name>` | Remove a skill by name |
|
||||||
|
| `/skill list` | List skills loaded in this session |
|
||||||
|
|
||||||
|
Dynamic skills take effect on the next message. This is useful for:
|
||||||
|
- Testing skills during development
|
||||||
|
- Temporarily adding capabilities to a model
|
||||||
|
- Experimenting with skill combinations
|
||||||
|
|
||||||
|
## Skill Reference Formats
|
||||||
|
|
||||||
|
Skills use a 5-part name structure: `host/namespace/kind/model:tag`
|
||||||
|
|
||||||
|
| Format | Example | Description |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| Local path | `./skills/calc` | Bundled with agent at create time |
|
||||||
|
| Library skill | `skill/calculator:1.0.0` | From the official skill library (library/skill/calculator) |
|
||||||
|
| User skill | `alice/skill/calc:1.0.0` | From a user's namespace |
|
||||||
|
| Full path | `registry.ollama.ai/alice/skill/calc:1.0.0` | Fully qualified with host |
|
||||||
|
|
||||||
|
The `kind` field distinguishes skills from models:
|
||||||
|
- `skill` - Skill packages
|
||||||
|
- `agent` - Agent packages (future)
|
||||||
|
- (empty) - Regular models
|
||||||
|
|
||||||
|
## SKILL.md Structure
|
||||||
|
|
||||||
|
### Required Frontmatter
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: skill-name # Must match directory name
|
||||||
|
description: Brief description of the skill
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Sections
|
||||||
|
|
||||||
|
1. **Purpose**: What the skill does and when to use it
|
||||||
|
2. **When to use**: Trigger conditions for the agent
|
||||||
|
3. **Instructions**: Step-by-step usage guide
|
||||||
|
4. **Examples**: Input/output examples
|
||||||
|
5. **Scripts**: Documentation for any bundled scripts
|
||||||
|
|
||||||
|
### Example: Calculator Skill
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: calculator
|
||||||
|
description: Performs mathematical calculations using Python
|
||||||
|
---
|
||||||
|
|
||||||
|
# Calculator Skill
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
This skill performs mathematical calculations using a bundled Python script.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
- User asks to calculate something
|
||||||
|
- User wants to do math operations
|
||||||
|
- Any arithmetic is needed
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
1. When calculation is needed, use the `run_skill_script` tool
|
||||||
|
2. Call: `python3 scripts/calculate.py "<expression>"`
|
||||||
|
3. Return the result to the user
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
**Input**: "What is 25 * 4?"
|
||||||
|
**Action**: `run_skill_script` with command `python3 scripts/calculate.py '25 * 4'`
|
||||||
|
**Output**: "25 * 4 = 100"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.ollama/models/
|
||||||
|
├── blobs/
|
||||||
|
│ └── sha256-<digest> # Skill tar.gz blob
|
||||||
|
├── manifests/
|
||||||
|
│ └── registry.ollama.ai/
|
||||||
|
│ └── skill/ # Library skills
|
||||||
|
│ └── calculator/
|
||||||
|
│ └── 1.0.0
|
||||||
|
│ └── skill-username/ # User skills
|
||||||
|
│ └── my-skill/
|
||||||
|
│ └── latest
|
||||||
|
└── skills/
|
||||||
|
└── sha256-<digest>/ # Extracted skill cache
|
||||||
|
├── SKILL.md
|
||||||
|
└── scripts/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Considerations
|
||||||
|
|
||||||
|
## Current State (Development)
|
||||||
|
|
||||||
|
The current implementation has several security considerations that need to be addressed before production use.
|
||||||
|
|
||||||
|
### 1. Script Execution
|
||||||
|
|
||||||
|
**Risk**: Skills can bundle arbitrary scripts that execute on the host system.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Scripts run with the same permissions as the Ollama process
|
||||||
|
- No sandboxing or isolation
|
||||||
|
- Full filesystem access
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] Sandbox script execution (containers, seccomp, etc.)
|
||||||
|
- [ ] Resource limits (CPU, memory, time)
|
||||||
|
- [ ] Filesystem isolation (read-only mounts, restricted paths)
|
||||||
|
- [ ] Network policy controls
|
||||||
|
- [ ] Capability dropping
|
||||||
|
|
||||||
|
### 2. Skill Provenance
|
||||||
|
|
||||||
|
**Risk**: Malicious skills could be pushed to the registry.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- No code signing or verification
|
||||||
|
- No malware scanning
|
||||||
|
- Trust based on namespace ownership
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] Skill signing with author keys
|
||||||
|
- [ ] Registry-side malware scanning
|
||||||
|
- [ ] Content policy enforcement
|
||||||
|
- [ ] Reputation system for skill authors
|
||||||
|
|
||||||
|
### 3. Namespace Squatting
|
||||||
|
|
||||||
|
**Risk**: Malicious actors could register skill names that impersonate official tools.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- First-come-first-served namespace registration
|
||||||
|
- No verification of skill names
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] Reserved namespace list (official tools, common names)
|
||||||
|
- [ ] Trademark/name verification for popular skills
|
||||||
|
- [ ] Clear namespacing conventions
|
||||||
|
|
||||||
|
### 4. Supply Chain Attacks
|
||||||
|
|
||||||
|
**Risk**: Compromised skills could inject malicious code into agents.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Skills pulled without integrity verification beyond digest
|
||||||
|
- No dependency tracking
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] SBOM (Software Bill of Materials) for skills
|
||||||
|
- [ ] Dependency vulnerability scanning
|
||||||
|
- [ ] Pinned versions in Agentfiles
|
||||||
|
- [ ] Audit logging of skill usage
|
||||||
|
|
||||||
|
### 5. Data Exfiltration
|
||||||
|
|
||||||
|
**Risk**: Skills could exfiltrate sensitive data from conversations or the host.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Skills have access to conversation context
|
||||||
|
- Scripts can make network requests
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] Network egress controls
|
||||||
|
- [ ] Sensitive data detection/masking
|
||||||
|
- [ ] Audit logging of script network activity
|
||||||
|
- [ ] User consent for data access
|
||||||
|
|
||||||
|
### 6. Privilege Escalation
|
||||||
|
|
||||||
|
**Risk**: Skills could escalate privileges through script execution.
|
||||||
|
|
||||||
|
**Current behavior**:
|
||||||
|
- Scripts inherit Ollama process privileges
|
||||||
|
- No capability restrictions
|
||||||
|
|
||||||
|
**Mitigations needed**:
|
||||||
|
- [ ] Run scripts as unprivileged user
|
||||||
|
- [ ] Drop all capabilities
|
||||||
|
- [ ] Mandatory access controls (SELinux/AppArmor)
|
||||||
|
|
||||||
|
## Recommended Security Model
|
||||||
|
|
||||||
|
### Skill Trust Levels
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Level 0: Untrusted (default) │
|
||||||
|
│ - No script execution │
|
||||||
|
│ - Instructions only │
|
||||||
|
│ - Safe for any skill │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Level 1: Sandboxed │
|
||||||
|
│ - Scripts run in isolated container │
|
||||||
|
│ - No network access │
|
||||||
|
│ - Read-only filesystem │
|
||||||
|
│ - Resource limits enforced │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Level 2: Trusted │
|
||||||
|
│ - Scripts run with network access │
|
||||||
|
│ - Can write to designated directories │
|
||||||
|
│ - Requires explicit user approval │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Level 3: Privileged (admin only) │
|
||||||
|
│ - Full host access │
|
||||||
|
│ - System administration skills │
|
||||||
|
│ - Requires admin approval │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skill Manifest Security Fields (Future)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
description: A skill description
|
||||||
|
security:
|
||||||
|
trust_level: sandboxed
|
||||||
|
permissions:
|
||||||
|
- network:read # Can make HTTP GET requests
|
||||||
|
- filesystem:read:/data # Can read from /data
|
||||||
|
resource_limits:
|
||||||
|
max_memory: 256MB
|
||||||
|
max_cpu_time: 30s
|
||||||
|
max_disk: 100MB
|
||||||
|
signature: sha256:abc... # Author signature
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Considerations
|
||||||
|
|
||||||
|
## Feature Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Foundation (Current)
|
||||||
|
- [x] Skill bundling with agents
|
||||||
|
- [x] Local skill development
|
||||||
|
- [x] Basic CLI commands (push, pull, list, rm, show)
|
||||||
|
- [x] Registry blob storage
|
||||||
|
- [ ] Registry namespace configuration
|
||||||
|
|
||||||
|
### Phase 2: Security
|
||||||
|
- [ ] Script sandboxing
|
||||||
|
- [ ] Permission model
|
||||||
|
- [ ] Skill signing
|
||||||
|
- [ ] Audit logging
|
||||||
|
|
||||||
|
### Phase 3: Discovery
|
||||||
|
- [ ] Skill search on ollama.com
|
||||||
|
- [ ] Skill ratings and reviews
|
||||||
|
- [ ] Usage analytics
|
||||||
|
- [ ] Featured/trending skills
|
||||||
|
|
||||||
|
### Phase 4: Advanced Features
|
||||||
|
- [ ] Skill dependencies
|
||||||
|
- [ ] Skill versioning constraints
|
||||||
|
- [ ] Skill composition (skills using skills)
|
||||||
|
- [ ] Skill testing framework
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### 1. Skill Execution Model
|
||||||
|
|
||||||
|
**Question**: How should skills execute scripts?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) In-process**: Fast but unsafe
|
||||||
|
- **B) Subprocess**: Current approach, moderate isolation
|
||||||
|
- **C) Container**: Good isolation, requires container runtime
|
||||||
|
- **D) WASM**: Portable and safe, limited capabilities
|
||||||
|
- **E) Remote execution**: Offload to secure service
|
||||||
|
|
||||||
|
### 2. Skill Versioning
|
||||||
|
|
||||||
|
**Question**: How strict should version pinning be?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) Always latest**: Simple but risky
|
||||||
|
- **B) Semantic versioning**: `^1.0.0` allows minor updates
|
||||||
|
- **C) Exact pinning**: `=1.0.0` requires explicit updates
|
||||||
|
- **D) Digest pinning**: `@sha256:abc` immutable reference
|
||||||
|
|
||||||
|
### 3. Skill Permissions
|
||||||
|
|
||||||
|
**Question**: How should users grant permissions to skills?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) All or nothing**: Accept all permissions or don't use
|
||||||
|
- **B) Granular consent**: Approve each permission individually
|
||||||
|
- **C) Trust levels**: Pre-defined permission bundles
|
||||||
|
- **D) Runtime prompts**: Ask when permission is first used
|
||||||
|
|
||||||
|
### 4. Skill Discovery
|
||||||
|
|
||||||
|
**Question**: How should users find skills?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) Central registry only**: ollama.com/skills
|
||||||
|
- **B) Federated registries**: Multiple skill sources
|
||||||
|
- **C) Git repositories**: Pull from GitHub, etc.
|
||||||
|
- **D) All of the above**: Multiple discovery mechanisms
|
||||||
|
|
||||||
|
### 5. Skill Monetization
|
||||||
|
|
||||||
|
**Question**: Should skill authors be able to monetize?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) Free only**: All skills are free and open
|
||||||
|
- **B) Paid skills**: Authors can charge for skills
|
||||||
|
- **C) Freemium**: Free tier with paid features
|
||||||
|
- **D) Donations**: Voluntary support for authors
|
||||||
|
|
||||||
|
### 6. Skill Updates
|
||||||
|
|
||||||
|
**Question**: How should skill updates be handled?
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- **A) Manual**: User explicitly updates
|
||||||
|
- **B) Auto-update**: Always use latest
|
||||||
|
- **C) Notify**: Alert user to available updates
|
||||||
|
- **D) Policy-based**: Organization controls update policy
|
||||||
|
|
||||||
|
## API Considerations
|
||||||
|
|
||||||
|
### Skill Metadata API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/skills
|
||||||
|
GET /api/skills/:namespace/:name
|
||||||
|
GET /api/skills/:namespace/:name/versions
|
||||||
|
GET /api/skills/:namespace/:name/readme
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skill Execution API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/skills/:namespace/:name/execute
|
||||||
|
{
|
||||||
|
"command": "python3 scripts/run.py",
|
||||||
|
"args": ["--input", "data"],
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skill Permissions API
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/skills/:namespace/:name/permissions
|
||||||
|
POST /api/skills/:namespace/:name/permissions/grant
|
||||||
|
DELETE /api/skills/:namespace/:name/permissions/revoke
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Considerations
|
||||||
|
|
||||||
|
### Skill Testing Framework
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run skill tests
|
||||||
|
ollama skill test ./my-skill
|
||||||
|
|
||||||
|
# Test with specific model
|
||||||
|
ollama skill test ./my-skill --model llama3.2:3b
|
||||||
|
|
||||||
|
# Generate test report
|
||||||
|
ollama skill test ./my-skill --report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test File Format
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# my-skill/tests/test.yaml
|
||||||
|
tests:
|
||||||
|
- name: "basic calculation"
|
||||||
|
input: "What is 2 + 2?"
|
||||||
|
expect:
|
||||||
|
contains: "4"
|
||||||
|
tool_called: "run_skill_script"
|
||||||
|
|
||||||
|
- name: "complex expression"
|
||||||
|
input: "Calculate 15% of 200"
|
||||||
|
expect:
|
||||||
|
contains: "30"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Compatibility Considerations
|
||||||
|
|
||||||
|
### Minimum Ollama Version
|
||||||
|
|
||||||
|
Skills should declare minimum Ollama version:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: my-skill
|
||||||
|
requires:
|
||||||
|
ollama: ">=0.4.0"
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Compatibility
|
||||||
|
|
||||||
|
Skills may require specific model capabilities:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: vision-skill
|
||||||
|
requires:
|
||||||
|
capabilities:
|
||||||
|
- vision
|
||||||
|
- tools
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### From Local to Registry
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Develop locally
|
||||||
|
SKILL ./my-skill
|
||||||
|
|
||||||
|
# Push when ready
|
||||||
|
ollama skill push myname/my-skill:1.0.0 ./my-skill
|
||||||
|
|
||||||
|
# Update Agentfile
|
||||||
|
SKILL skill/myname/my-skill:1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Version Upgrades
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for updates
|
||||||
|
ollama skill outdated
|
||||||
|
|
||||||
|
# Update specific skill
|
||||||
|
ollama skill update calculator:1.0.0
|
||||||
|
|
||||||
|
# Update all skills
|
||||||
|
ollama skill update --all
|
||||||
|
```
|
||||||
@@ -148,6 +148,16 @@ func Remotes() []string {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills returns the list of skill directories. Skills directories can be configured via the OLLAMA_SKILLS environment variable.
|
||||||
|
// Returns empty slice if not configured.
|
||||||
|
func Skills() []string {
|
||||||
|
raw := strings.TrimSpace(Var("OLLAMA_SKILLS"))
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return strings.Split(raw, ",")
|
||||||
|
}
|
||||||
|
|
||||||
func BoolWithDefault(k string) func(defaultValue bool) bool {
|
func BoolWithDefault(k string) func(defaultValue bool) bool {
|
||||||
return func(defaultValue bool) bool {
|
return func(defaultValue bool) bool {
|
||||||
if s := Var(k); s != "" {
|
if s := Var(k); s != "" {
|
||||||
@@ -317,6 +327,9 @@ func AsMap() map[string]EnvVar {
|
|||||||
ret["OLLAMA_VULKAN"] = EnvVar{"OLLAMA_VULKAN", EnableVulkan(), "Enable experimental Vulkan support"}
|
ret["OLLAMA_VULKAN"] = EnvVar{"OLLAMA_VULKAN", EnableVulkan(), "Enable experimental Vulkan support"}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills configuration would go here when added
|
||||||
|
ret["OLLAMA_SKILLS"] = EnvVar{"OLLAMA_SKILLS", Skills(), "Comma-separated list of skill directories"}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -87,5 +87,5 @@ require (
|
|||||||
golang.org/x/term v0.36.0
|
golang.org/x/term v0.36.0
|
||||||
golang.org/x/text v0.30.0
|
golang.org/x/text v0.30.0
|
||||||
google.golang.org/protobuf v1.34.1
|
google.golang.org/protobuf v1.34.1
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
126
parser/parser.go
126
parser/parser.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -58,6 +59,8 @@ func (f Modelfile) CreateRequest(relativeDir string) (*api.CreateRequest, error)
|
|||||||
|
|
||||||
var messages []api.Message
|
var messages []api.Message
|
||||||
var licenses []string
|
var licenses []string
|
||||||
|
var skills []api.SkillRef
|
||||||
|
var mcps []api.MCPRef
|
||||||
params := make(map[string]any)
|
params := make(map[string]any)
|
||||||
|
|
||||||
for _, c := range f.Commands {
|
for _, c := range f.Commands {
|
||||||
@@ -118,6 +121,32 @@ func (f Modelfile) CreateRequest(relativeDir string) (*api.CreateRequest, error)
|
|||||||
case "message":
|
case "message":
|
||||||
role, msg, _ := strings.Cut(c.Args, ": ")
|
role, msg, _ := strings.Cut(c.Args, ": ")
|
||||||
messages = append(messages, api.Message{Role: role, Content: msg})
|
messages = append(messages, api.Message{Role: role, Content: msg})
|
||||||
|
case "skill":
|
||||||
|
skillName := c.Args
|
||||||
|
// Expand local paths relative to the Agentfile directory
|
||||||
|
if isLocalPath(skillName) {
|
||||||
|
expanded, err := expandPath(skillName, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("expanding skill path %q: %w", skillName, err)
|
||||||
|
}
|
||||||
|
skillName = expanded
|
||||||
|
}
|
||||||
|
skills = append(skills, api.SkillRef{Name: skillName})
|
||||||
|
case "mcp":
|
||||||
|
mcpRef, err := parseMCPArg(c.Args, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid MCP: %w", err)
|
||||||
|
}
|
||||||
|
mcps = append(mcps, mcpRef)
|
||||||
|
case "agent_type":
|
||||||
|
// Handle "AGENT TYPE conversational" -> strip "TYPE " prefix
|
||||||
|
args := c.Args
|
||||||
|
if strings.HasPrefix(strings.ToLower(args), "type ") {
|
||||||
|
args = strings.TrimSpace(args[5:])
|
||||||
|
}
|
||||||
|
req.AgentType = args
|
||||||
|
case "entrypoint":
|
||||||
|
req.Entrypoint = c.Args
|
||||||
default:
|
default:
|
||||||
if slices.Contains(deprecatedParameters, c.Name) {
|
if slices.Contains(deprecatedParameters, c.Name) {
|
||||||
fmt.Printf("warning: parameter %s is deprecated\n", c.Name)
|
fmt.Printf("warning: parameter %s is deprecated\n", c.Name)
|
||||||
@@ -150,6 +179,12 @@ func (f Modelfile) CreateRequest(relativeDir string) (*api.CreateRequest, error)
|
|||||||
if len(licenses) > 0 {
|
if len(licenses) > 0 {
|
||||||
req.License = licenses
|
req.License = licenses
|
||||||
}
|
}
|
||||||
|
if len(skills) > 0 {
|
||||||
|
req.Skills = skills
|
||||||
|
}
|
||||||
|
if len(mcps) > 0 {
|
||||||
|
req.MCPs = mcps
|
||||||
|
}
|
||||||
|
|
||||||
return req, nil
|
return req, nil
|
||||||
}
|
}
|
||||||
@@ -333,7 +368,7 @@ func (c Command) String() string {
|
|||||||
switch c.Name {
|
switch c.Name {
|
||||||
case "model":
|
case "model":
|
||||||
fmt.Fprintf(&sb, "FROM %s", c.Args)
|
fmt.Fprintf(&sb, "FROM %s", c.Args)
|
||||||
case "license", "template", "system", "adapter", "renderer", "parser", "requires":
|
case "license", "template", "system", "adapter", "renderer", "parser", "requires", "skill", "agent_type", "entrypoint":
|
||||||
fmt.Fprintf(&sb, "%s %s", strings.ToUpper(c.Name), quote(c.Args))
|
fmt.Fprintf(&sb, "%s %s", strings.ToUpper(c.Name), quote(c.Args))
|
||||||
case "message":
|
case "message":
|
||||||
role, message, _ := strings.Cut(c.Args, ": ")
|
role, message, _ := strings.Cut(c.Args, ": ")
|
||||||
@@ -359,7 +394,7 @@ const (
|
|||||||
var (
|
var (
|
||||||
errMissingFrom = errors.New("no FROM line")
|
errMissingFrom = errors.New("no FROM line")
|
||||||
errInvalidMessageRole = errors.New("message role must be one of \"system\", \"user\", or \"assistant\"")
|
errInvalidMessageRole = errors.New("message role must be one of \"system\", \"user\", or \"assistant\"")
|
||||||
errInvalidCommand = errors.New("command must be one of \"from\", \"license\", \"template\", \"system\", \"adapter\", \"renderer\", \"parser\", \"parameter\", \"message\", or \"requires\"")
|
errInvalidCommand = errors.New("command must be one of \"from\", \"license\", \"template\", \"system\", \"adapter\", \"renderer\", \"parser\", \"parameter\", \"message\", \"requires\", \"skill\", \"agent_type\", \"mcp\", or \"entrypoint\"")
|
||||||
)
|
)
|
||||||
|
|
||||||
type ParserError struct {
|
type ParserError struct {
|
||||||
@@ -423,6 +458,9 @@ func ParseFile(r io.Reader) (*Modelfile, error) {
|
|||||||
switch s := strings.ToLower(b.String()); s {
|
switch s := strings.ToLower(b.String()); s {
|
||||||
case "from":
|
case "from":
|
||||||
cmd.Name = "model"
|
cmd.Name = "model"
|
||||||
|
case "agent":
|
||||||
|
// "AGENT TYPE" -> "agent_type", consume next word
|
||||||
|
cmd.Name = "agent_type"
|
||||||
case "parameter":
|
case "parameter":
|
||||||
// transition to stateParameter which sets command name
|
// transition to stateParameter which sets command name
|
||||||
next = stateParameter
|
next = stateParameter
|
||||||
@@ -500,6 +538,10 @@ func ParseFile(r io.Reader) (*Modelfile, error) {
|
|||||||
if cmd.Name == "model" {
|
if cmd.Name == "model" {
|
||||||
return &f, nil
|
return &f, nil
|
||||||
}
|
}
|
||||||
|
// Allow entrypoint-only agents without FROM
|
||||||
|
if cmd.Name == "entrypoint" {
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errMissingFrom
|
return nil, errMissingFrom
|
||||||
@@ -518,7 +560,7 @@ func parseRuneForState(r rune, cs state) (state, rune, error) {
|
|||||||
}
|
}
|
||||||
case stateName:
|
case stateName:
|
||||||
switch {
|
switch {
|
||||||
case isAlpha(r):
|
case isAlpha(r), r == '_':
|
||||||
return stateName, r, nil
|
return stateName, r, nil
|
||||||
case isSpace(r):
|
case isSpace(r):
|
||||||
return stateValue, 0, nil
|
return stateValue, 0, nil
|
||||||
@@ -619,7 +661,7 @@ func isValidMessageRole(role string) bool {
|
|||||||
|
|
||||||
func isValidCommand(cmd string) bool {
|
func isValidCommand(cmd string) bool {
|
||||||
switch strings.ToLower(cmd) {
|
switch strings.ToLower(cmd) {
|
||||||
case "from", "license", "template", "system", "adapter", "renderer", "parser", "parameter", "message", "requires":
|
case "from", "license", "template", "system", "adapter", "renderer", "parser", "parameter", "message", "requires", "skill", "agent_type", "agent", "mcp", "entrypoint":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -666,3 +708,79 @@ func expandPathImpl(path, relativeDir string, currentUserFunc func() (*user.User
|
|||||||
func expandPath(path, relativeDir string) (string, error) {
|
func expandPath(path, relativeDir string) (string, error) {
|
||||||
return expandPathImpl(path, relativeDir, user.Current, user.Lookup)
|
return expandPathImpl(path, relativeDir, user.Current, user.Lookup)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseMCPArg parses MCP command arguments.
|
||||||
|
// Supports two formats:
|
||||||
|
//
|
||||||
|
// JSON: {"name": "web-search", "command": "uv", "args": ["run", "./script.py"]}
|
||||||
|
// Simple: web-search uv run ./script.py (name, command, args...)
|
||||||
|
func parseMCPArg(args string, relativeDir string) (api.MCPRef, error) {
|
||||||
|
args = strings.TrimSpace(args)
|
||||||
|
if args == "" {
|
||||||
|
return api.MCPRef{}, errors.New("MCP requires arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JSON format first
|
||||||
|
if strings.HasPrefix(args, "{") {
|
||||||
|
var ref api.MCPRef
|
||||||
|
if err := json.Unmarshal([]byte(args), &ref); err != nil {
|
||||||
|
return api.MCPRef{}, fmt.Errorf("invalid JSON: %w", err)
|
||||||
|
}
|
||||||
|
if ref.Name == "" {
|
||||||
|
return api.MCPRef{}, errors.New("MCP name is required")
|
||||||
|
}
|
||||||
|
if ref.Command == "" {
|
||||||
|
return api.MCPRef{}, errors.New("MCP command is required")
|
||||||
|
}
|
||||||
|
if ref.Type == "" {
|
||||||
|
ref.Type = "stdio"
|
||||||
|
}
|
||||||
|
// Expand relative paths in args
|
||||||
|
for i, arg := range ref.Args {
|
||||||
|
if isLocalPath(arg) {
|
||||||
|
expanded, err := expandPath(arg, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return api.MCPRef{}, fmt.Errorf("expanding path %q: %w", arg, err)
|
||||||
|
}
|
||||||
|
ref.Args[i] = expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple format: name command args...
|
||||||
|
parts := strings.Fields(args)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return api.MCPRef{}, errors.New("MCP requires at least name and command")
|
||||||
|
}
|
||||||
|
|
||||||
|
ref := api.MCPRef{
|
||||||
|
Name: parts[0],
|
||||||
|
Command: parts[1],
|
||||||
|
Type: "stdio",
|
||||||
|
}
|
||||||
|
if len(parts) > 2 {
|
||||||
|
ref.Args = parts[2:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand relative paths in args
|
||||||
|
for i, arg := range ref.Args {
|
||||||
|
if isLocalPath(arg) {
|
||||||
|
expanded, err := expandPath(arg, relativeDir)
|
||||||
|
if err != nil {
|
||||||
|
return api.MCPRef{}, fmt.Errorf("expanding path %q: %w", arg, err)
|
||||||
|
}
|
||||||
|
ref.Args[i] = expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalPath checks if a string looks like a local filesystem path.
|
||||||
|
func isLocalPath(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "/") ||
|
||||||
|
strings.HasPrefix(s, "./") ||
|
||||||
|
strings.HasPrefix(s, "../") ||
|
||||||
|
strings.HasPrefix(s, "~")
|
||||||
|
}
|
||||||
|
|||||||
176
server/create.go
176
server/create.go
@@ -62,6 +62,10 @@ func (s *Server) CreateHandler(c *gin.Context) {
|
|||||||
config.Renderer = r.Renderer
|
config.Renderer = r.Renderer
|
||||||
config.Parser = r.Parser
|
config.Parser = r.Parser
|
||||||
config.Requires = r.Requires
|
config.Requires = r.Requires
|
||||||
|
config.Skills = r.Skills
|
||||||
|
config.MCPs = r.MCPs
|
||||||
|
config.AgentType = r.AgentType
|
||||||
|
config.Entrypoint = r.Entrypoint
|
||||||
|
|
||||||
for v := range r.Files {
|
for v := range r.Files {
|
||||||
if !fs.ValidPath(v) {
|
if !fs.ValidPath(v) {
|
||||||
@@ -121,7 +125,10 @@ func (s *Server) CreateHandler(c *gin.Context) {
|
|||||||
ch <- gin.H{"error": err.Error()}
|
ch <- gin.H{"error": err.Error()}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == nil && !remote && (config.Renderer == "" || config.Parser == "" || config.Requires == "") {
|
// Inherit config from base model (Renderer, Parser, Requires, Capabilities, etc.)
|
||||||
|
// This is especially important for cloud models which don't have GGUF files
|
||||||
|
// to detect capabilities from.
|
||||||
|
if err == nil && !remote {
|
||||||
manifest, mErr := ParseNamedManifest(fromName)
|
manifest, mErr := ParseNamedManifest(fromName)
|
||||||
if mErr == nil && manifest.Config.Digest != "" {
|
if mErr == nil && manifest.Config.Digest != "" {
|
||||||
configPath, pErr := GetBlobsPath(manifest.Config.Digest)
|
configPath, pErr := GetBlobsPath(manifest.Config.Digest)
|
||||||
@@ -138,6 +145,29 @@ func (s *Server) CreateHandler(c *gin.Context) {
|
|||||||
if config.Requires == "" {
|
if config.Requires == "" {
|
||||||
config.Requires = baseConfig.Requires
|
config.Requires = baseConfig.Requires
|
||||||
}
|
}
|
||||||
|
// Inherit capabilities for cloud/remote models
|
||||||
|
// (local models detect capabilities from GGUF file)
|
||||||
|
if len(config.Capabilities) == 0 && len(baseConfig.Capabilities) > 0 {
|
||||||
|
config.Capabilities = baseConfig.Capabilities
|
||||||
|
}
|
||||||
|
// Inherit remote host/model if base is a cloud model
|
||||||
|
if config.RemoteHost == "" && baseConfig.RemoteHost != "" {
|
||||||
|
config.RemoteHost = baseConfig.RemoteHost
|
||||||
|
}
|
||||||
|
if config.RemoteModel == "" && baseConfig.RemoteModel != "" {
|
||||||
|
config.RemoteModel = baseConfig.RemoteModel
|
||||||
|
}
|
||||||
|
// Inherit model family for proper rendering
|
||||||
|
if config.ModelFamily == "" && baseConfig.ModelFamily != "" {
|
||||||
|
config.ModelFamily = baseConfig.ModelFamily
|
||||||
|
}
|
||||||
|
if len(config.ModelFamilies) == 0 && len(baseConfig.ModelFamilies) > 0 {
|
||||||
|
config.ModelFamilies = baseConfig.ModelFamilies
|
||||||
|
}
|
||||||
|
// Inherit context length for cloud models
|
||||||
|
if config.ContextLen == 0 && baseConfig.ContextLen > 0 {
|
||||||
|
config.ContextLen = baseConfig.ContextLen
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cfgFile.Close()
|
cfgFile.Close()
|
||||||
}
|
}
|
||||||
@@ -157,6 +187,9 @@ func (s *Server) CreateHandler(c *gin.Context) {
|
|||||||
ch <- gin.H{"error": err.Error()}
|
ch <- gin.H{"error": err.Error()}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else if r.Entrypoint != "" {
|
||||||
|
// Entrypoint-only agent: no base model needed
|
||||||
|
slog.Debug("create entrypoint-only agent", "entrypoint", r.Entrypoint)
|
||||||
} else {
|
} else {
|
||||||
ch <- gin.H{"error": errNeitherFromOrFiles.Error(), "status": http.StatusBadRequest}
|
ch <- gin.H{"error": errNeitherFromOrFiles.Error(), "status": http.StatusBadRequest}
|
||||||
return
|
return
|
||||||
@@ -543,6 +576,18 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle skill layers for agents
|
||||||
|
layers, config.Skills, err = setSkillLayers(layers, config.Skills, fn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle MCP layers for agents
|
||||||
|
layers, config.MCPs, err = setMCPLayers(layers, config.MCPs, fn)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
configLayer, err := createConfigLayer(layers, *config)
|
configLayer, err := createConfigLayer(layers, *config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -793,6 +838,135 @@ func setMessages(layers []Layer, m []api.Message) ([]Layer, error) {
|
|||||||
return layers, nil
|
return layers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setSkillLayers creates skill layers for local skill paths and updates the skill refs.
|
||||||
|
// Local paths are converted to bundled skill layers with digests.
|
||||||
|
// Registry references are kept as-is for later resolution during pull.
|
||||||
|
func setSkillLayers(layers []Layer, skills []model.SkillRef, fn func(resp api.ProgressResponse)) ([]Layer, []model.SkillRef, error) {
|
||||||
|
if len(skills) == 0 {
|
||||||
|
return layers, skills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing skill layers
|
||||||
|
layers = removeLayer(layers, MediaTypeSkill)
|
||||||
|
|
||||||
|
var updatedSkills []model.SkillRef
|
||||||
|
|
||||||
|
for _, skill := range skills {
|
||||||
|
// Check if this is a local path
|
||||||
|
if IsLocalSkillPath(skill.Name) {
|
||||||
|
// Expand home directory if needed
|
||||||
|
skillPath := skill.Name
|
||||||
|
if strings.HasPrefix(skillPath, "~") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("expanding home directory: %w", err)
|
||||||
|
}
|
||||||
|
skillPath = filepath.Join(home, skillPath[1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make absolute
|
||||||
|
absPath, err := filepath.Abs(skillPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("resolving skill path %q: %w", skill.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a direct skill directory or a parent containing skills
|
||||||
|
skillMdPath := filepath.Join(absPath, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillMdPath); err == nil {
|
||||||
|
// Direct skill directory
|
||||||
|
fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", filepath.Base(absPath))})
|
||||||
|
|
||||||
|
layer, err := CreateSkillLayer(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("creating skill layer for %q: %w", skill.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
layers = append(layers, layer)
|
||||||
|
updatedSkills = append(updatedSkills, model.SkillRef{
|
||||||
|
Name: filepath.Base(absPath),
|
||||||
|
Digest: layer.Digest,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Parent directory - walk to find skill subdirectories
|
||||||
|
err := filepath.WalkDir(absPath, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.Name() != "SKILL.md" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
skillDir := filepath.Dir(path)
|
||||||
|
skillName := filepath.Base(skillDir)
|
||||||
|
fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", skillName)})
|
||||||
|
|
||||||
|
layer, err := CreateSkillLayer(skillDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating skill layer for %q: %w", skillDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
layers = append(layers, layer)
|
||||||
|
updatedSkills = append(updatedSkills, model.SkillRef{
|
||||||
|
Name: skillName,
|
||||||
|
Digest: layer.Digest,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("walking skill directory %q: %w", skill.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if skill.Digest != "" {
|
||||||
|
// Already has a digest (from a pulled agent), keep as-is
|
||||||
|
updatedSkills = append(updatedSkills, skill)
|
||||||
|
} else {
|
||||||
|
// Registry reference - keep as-is for later resolution
|
||||||
|
updatedSkills = append(updatedSkills, skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers, updatedSkills, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMCPLayers handles MCP server references.
|
||||||
|
// Currently, MCPs are stored as config data (command/args).
|
||||||
|
// Future: support bundling MCP server directories as layers.
|
||||||
|
func setMCPLayers(layers []Layer, mcps []model.MCPRef, fn func(resp api.ProgressResponse)) ([]Layer, []model.MCPRef, error) {
|
||||||
|
if len(mcps) == 0 {
|
||||||
|
return layers, mcps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing MCP layers
|
||||||
|
layers = removeLayer(layers, MediaTypeMCP)
|
||||||
|
|
||||||
|
var updatedMCPs []model.MCPRef
|
||||||
|
|
||||||
|
for _, mcp := range mcps {
|
||||||
|
// Validate MCP has required fields
|
||||||
|
if mcp.Name == "" {
|
||||||
|
return nil, nil, fmt.Errorf("MCP server requires a name")
|
||||||
|
}
|
||||||
|
if mcp.Command == "" {
|
||||||
|
return nil, nil, fmt.Errorf("MCP server %q requires a command", mcp.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default type if not specified
|
||||||
|
if mcp.Type == "" {
|
||||||
|
mcp.Type = "stdio"
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, just keep MCPs as config data
|
||||||
|
// Future: detect local paths in args and bundle them
|
||||||
|
updatedMCPs = append(updatedMCPs, mcp)
|
||||||
|
fn(api.ProgressResponse{Status: fmt.Sprintf("configuring MCP: %s", mcp.Name)})
|
||||||
|
}
|
||||||
|
|
||||||
|
return layers, updatedMCPs, nil
|
||||||
|
}
|
||||||
|
|
||||||
func createConfigLayer(layers []Layer, config model.ConfigV2) (*Layer, error) {
|
func createConfigLayer(layers []Layer, config model.ConfigV2) (*Layer, error) {
|
||||||
digests := make([]string, len(layers))
|
digests := make([]string, len(layers))
|
||||||
for i, layer := range layers {
|
for i, layer := range layers {
|
||||||
|
|||||||
@@ -232,6 +232,13 @@ func (m *Model) String() string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.Config.Entrypoint != "" {
|
||||||
|
modelfile.Commands = append(modelfile.Commands, parser.Command{
|
||||||
|
Name: "entrypoint",
|
||||||
|
Args: m.Config.Entrypoint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range m.Options {
|
for k, v := range m.Options {
|
||||||
switch v := v.(type) {
|
switch v := v.(type) {
|
||||||
case []any:
|
case []any:
|
||||||
@@ -657,6 +664,16 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract skill layers to the skills cache
|
||||||
|
for _, layer := range manifest.Layers {
|
||||||
|
if layer.MediaType == MediaTypeSkill {
|
||||||
|
fn(api.ProgressResponse{Status: fmt.Sprintf("extracting skill %s", layer.Digest)})
|
||||||
|
if _, err := ExtractSkillBlob(layer.Digest); err != nil {
|
||||||
|
return fmt.Errorf("extracting skill layer %s: %w", layer.Digest, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn(api.ProgressResponse{Status: "writing manifest"})
|
fn(api.ProgressResponse{Status: "writing manifest"})
|
||||||
|
|
||||||
manifestJSON, err := json.Marshal(manifest)
|
manifestJSON, err := json.Marshal(manifest)
|
||||||
|
|||||||
@@ -129,11 +129,30 @@ func Manifests(continueOnError bool) (map[model.Name]*Manifest, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(mxyng): use something less brittle
|
// Find both 4-part (models) and 5-part (skills/agents) manifest paths
|
||||||
matches, err := filepath.Glob(filepath.Join(manifests, "*", "*", "*", "*"))
|
matches4, err := filepath.Glob(filepath.Join(manifests, "*", "*", "*", "*"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
matches5, err := filepath.Glob(filepath.Join(manifests, "*", "*", "*", "*", "*"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine matches, filtering to only include files
|
||||||
|
var matches []string
|
||||||
|
for _, match := range matches4 {
|
||||||
|
fi, err := os.Stat(match)
|
||||||
|
if err == nil && !fi.IsDir() {
|
||||||
|
matches = append(matches, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, match := range matches5 {
|
||||||
|
fi, err := os.Stat(match)
|
||||||
|
if err == nil && !fi.IsDir() {
|
||||||
|
matches = append(matches, match)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ms := make(map[model.Name]*Manifest)
|
ms := make(map[model.Name]*Manifest)
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
|
|||||||
315
server/mcp.go
Normal file
315
server/mcp.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaTypeMCP is the media type for MCP server layers in manifests.
|
||||||
|
const MediaTypeMCP = "application/vnd.ollama.image.mcp"
|
||||||
|
|
||||||
|
// GetMCPsPath returns the path to the extracted MCPs cache directory.
|
||||||
|
// If digest is empty, returns the mcps directory itself.
|
||||||
|
// If digest is provided, returns the path to the extracted MCP for that digest.
|
||||||
|
func GetMCPsPath(digest string) (string, error) {
|
||||||
|
// only accept actual sha256 digests
|
||||||
|
pattern := "^sha256[:-][0-9a-fA-F]{64}$"
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
|
||||||
|
if digest != "" && !re.MatchString(digest) {
|
||||||
|
return "", ErrInvalidDigestFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
digest = strings.ReplaceAll(digest, ":", "-")
|
||||||
|
path := filepath.Join(envconfig.Models(), "mcps", digest)
|
||||||
|
dirPath := filepath.Dir(path)
|
||||||
|
if digest == "" {
|
||||||
|
dirPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dirPath, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: ensure path elements are traversable", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractMCPBlob extracts an MCP tar.gz blob to the mcps cache.
|
||||||
|
// The blob is expected to be at the blobs path for the given digest.
|
||||||
|
// Returns the path to the extracted MCP directory.
|
||||||
|
func ExtractMCPBlob(digest string) (string, error) {
|
||||||
|
// Get the blob path
|
||||||
|
blobPath, err := GetBlobsPath(digest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getting blob path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the extraction path
|
||||||
|
mcpPath, err := GetMCPsPath(digest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getting mcp path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already extracted (look for any file)
|
||||||
|
entries, err := os.ReadDir(mcpPath)
|
||||||
|
if err == nil && len(entries) > 0 {
|
||||||
|
return mcpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the blob
|
||||||
|
f, err := os.Open(blobPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("opening blob: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Create gzip reader
|
||||||
|
gzr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
// Create tar reader
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
|
||||||
|
// Create the mcp directory
|
||||||
|
if err := os.MkdirAll(mcpPath, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating mcp directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the name and ensure it doesn't escape the target directory
|
||||||
|
name := filepath.Clean(header.Name)
|
||||||
|
if strings.HasPrefix(name, "..") {
|
||||||
|
return "", fmt.Errorf("invalid path in archive: %s", header.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(mcpPath, name)
|
||||||
|
|
||||||
|
// Verify the target is within mcpPath
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(mcpPath)+string(os.PathSeparator)) && target != filepath.Clean(mcpPath) {
|
||||||
|
return "", fmt.Errorf("path escapes mcp directory: %s", header.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating directory: %w", err)
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return "", fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mcpPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMCPLayer creates an MCP layer from a local directory.
|
||||||
|
// The directory can optionally contain an mcp.json or package.json file.
|
||||||
|
// Returns the created layer.
|
||||||
|
func CreateMCPLayer(mcpDir string) (Layer, error) {
|
||||||
|
// Verify directory exists
|
||||||
|
info, err := os.Stat(mcpDir)
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("mcp directory not found: %w", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return Layer{}, fmt.Errorf("mcp path is not a directory: %s", mcpDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file for the tar.gz
|
||||||
|
blobsPath, err := GetBlobsPath("")
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("getting blobs path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp(blobsPath, "mcp-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer func() {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create gzip writer
|
||||||
|
gzw := gzip.NewWriter(tmpFile)
|
||||||
|
defer gzw.Close()
|
||||||
|
|
||||||
|
// Create tar writer
|
||||||
|
tw := tar.NewWriter(gzw)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
// Walk the mcp directory and add files to tar
|
||||||
|
err = filepath.Walk(mcpDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(mcpDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the root directory itself
|
||||||
|
if relPath == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file contents if it's a regular file
|
||||||
|
if !info.IsDir() {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tw, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating tar archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close writers to flush
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing tar writer: %w", err)
|
||||||
|
}
|
||||||
|
if err := gzw.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing gzip writer: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the temp file for reading
|
||||||
|
tmpFile, err = os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("reopening temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
// Create the layer (this will compute the digest and move to blobs)
|
||||||
|
layer, err := NewLayer(tmpFile, MediaTypeMCP)
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating layer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the mcp to the cache so it's ready to use
|
||||||
|
if _, err := ExtractMCPBlob(layer.Digest); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("extracting mcp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return layer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLocalMCPPath checks if an MCP reference looks like a local path.
|
||||||
|
// Local paths are explicitly prefixed with /, ./, ../, or ~.
|
||||||
|
func IsLocalMCPPath(name string) bool {
|
||||||
|
return strings.HasPrefix(name, "/") ||
|
||||||
|
strings.HasPrefix(name, "./") ||
|
||||||
|
strings.HasPrefix(name, "../") ||
|
||||||
|
strings.HasPrefix(name, "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPNamespace is the namespace used for standalone MCPs in the registry.
|
||||||
|
const MCPNamespace = "mcp"
|
||||||
|
|
||||||
|
// IsMCPReference checks if a name refers to an MCP (has mcp/ prefix).
|
||||||
|
func IsMCPReference(name string) bool {
|
||||||
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "/")
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
|
||||||
|
// mcp/name or mcp/name:tag
|
||||||
|
if len(parts) >= 1 && parts[0] == MCPNamespace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// namespace/mcp/name (e.g., myuser/mcp/websearch)
|
||||||
|
if len(parts) >= 2 && parts[1] == MCPNamespace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseMCPName parses an MCP reference string into a model.Name.
|
||||||
|
// The Kind field is set to "mcp".
|
||||||
|
func ParseMCPName(name string) model.Name {
|
||||||
|
n := model.ParseName(name)
|
||||||
|
|
||||||
|
// If Kind wasn't set (old format without mcp/), set it
|
||||||
|
if n.Kind == "" {
|
||||||
|
n.Kind = MCPNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMCPManifestPath returns the path to the MCP manifest file.
|
||||||
|
func GetMCPManifestPath(n model.Name) (string, error) {
|
||||||
|
if n.Model == "" {
|
||||||
|
return "", fmt.Errorf("mcp name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Kind is set
|
||||||
|
if n.Kind == "" {
|
||||||
|
n.Kind = MCPNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(
|
||||||
|
envconfig.Models(),
|
||||||
|
"manifests",
|
||||||
|
n.Filepath(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type ModelPath struct {
|
|||||||
ProtocolScheme string
|
ProtocolScheme string
|
||||||
Registry string
|
Registry string
|
||||||
Namespace string
|
Namespace string
|
||||||
|
Kind string // Optional: "skill", "agent", or empty for models
|
||||||
Repository string
|
Repository string
|
||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,7 @@ func ParseModelPath(name string) ModelPath {
|
|||||||
ProtocolScheme: DefaultProtocolScheme,
|
ProtocolScheme: DefaultProtocolScheme,
|
||||||
Registry: DefaultRegistry,
|
Registry: DefaultRegistry,
|
||||||
Namespace: DefaultNamespace,
|
Namespace: DefaultNamespace,
|
||||||
|
Kind: "",
|
||||||
Repository: "",
|
Repository: "",
|
||||||
Tag: DefaultTag,
|
Tag: DefaultTag,
|
||||||
}
|
}
|
||||||
@@ -55,13 +57,41 @@ func ParseModelPath(name string) ModelPath {
|
|||||||
name = strings.ReplaceAll(name, string(os.PathSeparator), "/")
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "/")
|
||||||
parts := strings.Split(name, "/")
|
parts := strings.Split(name, "/")
|
||||||
switch len(parts) {
|
switch len(parts) {
|
||||||
case 3:
|
case 4:
|
||||||
|
// host/namespace/kind/model or host/namespace/model:tag with kind
|
||||||
mp.Registry = parts[0]
|
mp.Registry = parts[0]
|
||||||
mp.Namespace = parts[1]
|
mp.Namespace = parts[1]
|
||||||
mp.Repository = parts[2]
|
if model.ValidKinds[parts[2]] {
|
||||||
|
mp.Kind = parts[2]
|
||||||
|
mp.Repository = parts[3]
|
||||||
|
} else {
|
||||||
|
// Not a valid kind, treat as old format with extra part
|
||||||
|
mp.Repository = parts[3]
|
||||||
|
}
|
||||||
|
case 3:
|
||||||
|
// Could be: host/namespace/model OR namespace/kind/model
|
||||||
|
if model.ValidKinds[parts[1]] {
|
||||||
|
// namespace/kind/model
|
||||||
|
mp.Namespace = parts[0]
|
||||||
|
mp.Kind = parts[1]
|
||||||
|
mp.Repository = parts[2]
|
||||||
|
} else {
|
||||||
|
// host/namespace/model
|
||||||
|
mp.Registry = parts[0]
|
||||||
|
mp.Namespace = parts[1]
|
||||||
|
mp.Repository = parts[2]
|
||||||
|
}
|
||||||
case 2:
|
case 2:
|
||||||
mp.Namespace = parts[0]
|
// Could be: namespace/model OR kind/model
|
||||||
mp.Repository = parts[1]
|
if model.ValidKinds[parts[0]] {
|
||||||
|
// kind/model (library skill)
|
||||||
|
mp.Kind = parts[0]
|
||||||
|
mp.Repository = parts[1]
|
||||||
|
} else {
|
||||||
|
// namespace/model
|
||||||
|
mp.Namespace = parts[0]
|
||||||
|
mp.Repository = parts[1]
|
||||||
|
}
|
||||||
case 1:
|
case 1:
|
||||||
mp.Repository = parts[0]
|
mp.Repository = parts[0]
|
||||||
}
|
}
|
||||||
@@ -75,20 +105,35 @@ func ParseModelPath(name string) ModelPath {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (mp ModelPath) GetNamespaceRepository() string {
|
func (mp ModelPath) GetNamespaceRepository() string {
|
||||||
|
if mp.Kind != "" {
|
||||||
|
return fmt.Sprintf("%s/%s/%s", mp.Namespace, mp.Kind, mp.Repository)
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s/%s", mp.Namespace, mp.Repository)
|
return fmt.Sprintf("%s/%s", mp.Namespace, mp.Repository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mp ModelPath) GetFullTagname() string {
|
func (mp ModelPath) GetFullTagname() string {
|
||||||
|
if mp.Kind != "" {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Kind, mp.Repository, mp.Tag)
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Repository, mp.Tag)
|
return fmt.Sprintf("%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Repository, mp.Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mp ModelPath) GetShortTagname() string {
|
func (mp ModelPath) GetShortTagname() string {
|
||||||
if mp.Registry == DefaultRegistry {
|
if mp.Registry == DefaultRegistry {
|
||||||
if mp.Namespace == DefaultNamespace {
|
if mp.Namespace == DefaultNamespace {
|
||||||
|
if mp.Kind != "" {
|
||||||
|
return fmt.Sprintf("%s/%s:%s", mp.Kind, mp.Repository, mp.Tag)
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s:%s", mp.Repository, mp.Tag)
|
return fmt.Sprintf("%s:%s", mp.Repository, mp.Tag)
|
||||||
}
|
}
|
||||||
|
if mp.Kind != "" {
|
||||||
|
return fmt.Sprintf("%s/%s/%s:%s", mp.Namespace, mp.Kind, mp.Repository, mp.Tag)
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s/%s:%s", mp.Namespace, mp.Repository, mp.Tag)
|
return fmt.Sprintf("%s/%s:%s", mp.Namespace, mp.Repository, mp.Tag)
|
||||||
}
|
}
|
||||||
|
if mp.Kind != "" {
|
||||||
|
return fmt.Sprintf("%s/%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Kind, mp.Repository, mp.Tag)
|
||||||
|
}
|
||||||
return fmt.Sprintf("%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Repository, mp.Tag)
|
return fmt.Sprintf("%s/%s/%s:%s", mp.Registry, mp.Namespace, mp.Repository, mp.Tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +142,7 @@ func (mp ModelPath) GetManifestPath() (string, error) {
|
|||||||
name := model.Name{
|
name := model.Name{
|
||||||
Host: mp.Registry,
|
Host: mp.Registry,
|
||||||
Namespace: mp.Namespace,
|
Namespace: mp.Namespace,
|
||||||
|
Kind: mp.Kind,
|
||||||
Model: mp.Repository,
|
Model: mp.Repository,
|
||||||
Tag: mp.Tag,
|
Tag: mp.Tag,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -978,6 +978,9 @@ func getExistingName(n model.Name) (model.Name, error) {
|
|||||||
if set.Namespace == "" && strings.EqualFold(e.Namespace, n.Namespace) {
|
if set.Namespace == "" && strings.EqualFold(e.Namespace, n.Namespace) {
|
||||||
n.Namespace = e.Namespace
|
n.Namespace = e.Namespace
|
||||||
}
|
}
|
||||||
|
if set.Kind == "" && strings.EqualFold(e.Kind, n.Kind) {
|
||||||
|
n.Kind = e.Kind
|
||||||
|
}
|
||||||
if set.Model == "" && strings.EqualFold(e.Model, n.Model) {
|
if set.Model == "" && strings.EqualFold(e.Model, n.Model) {
|
||||||
n.Model = e.Model
|
n.Model = e.Model
|
||||||
}
|
}
|
||||||
@@ -1116,6 +1119,10 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) {
|
|||||||
Capabilities: m.Capabilities(),
|
Capabilities: m.Capabilities(),
|
||||||
ModifiedAt: manifest.fi.ModTime(),
|
ModifiedAt: manifest.fi.ModTime(),
|
||||||
Requires: m.Config.Requires,
|
Requires: m.Config.Requires,
|
||||||
|
Skills: m.Config.Skills,
|
||||||
|
MCPs: m.Config.MCPs,
|
||||||
|
AgentType: m.Config.AgentType,
|
||||||
|
Entrypoint: m.Config.Entrypoint,
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.Config.RemoteHost != "" {
|
if m.Config.RemoteHost != "" {
|
||||||
@@ -1170,11 +1177,16 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) {
|
|||||||
fmt.Fprint(&sb, m.String())
|
fmt.Fprint(&sb, m.String())
|
||||||
resp.Modelfile = sb.String()
|
resp.Modelfile = sb.String()
|
||||||
|
|
||||||
// skip loading tensor information if this is a remote model
|
// skip loading tensor information if this is a remote model or a skill
|
||||||
if m.Config.RemoteHost != "" && m.Config.RemoteModel != "" {
|
if m.Config.RemoteHost != "" && m.Config.RemoteModel != "" {
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skills don't have model weights, skip tensor loading
|
||||||
|
if m.ModelPath == "" {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
kvData, tensors, err := getModelData(m.ModelPath, req.Verbose)
|
kvData, tensors, err := getModelData(m.ModelPath, req.Verbose)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
326
server/skill.go
Normal file
326
server/skill.go
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/envconfig"
|
||||||
|
"github.com/ollama/ollama/types/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MediaTypeSkill is the media type for skill layers in manifests.
|
||||||
|
const MediaTypeSkill = "application/vnd.ollama.image.skill"
|
||||||
|
|
||||||
|
// GetSkillsPath returns the path to the extracted skills cache directory.
|
||||||
|
// If digest is empty, returns the skills directory itself.
|
||||||
|
// If digest is provided, returns the path to the extracted skill for that digest.
|
||||||
|
func GetSkillsPath(digest string) (string, error) {
|
||||||
|
// only accept actual sha256 digests
|
||||||
|
pattern := "^sha256[:-][0-9a-fA-F]{64}$"
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
|
||||||
|
if digest != "" && !re.MatchString(digest) {
|
||||||
|
return "", ErrInvalidDigestFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
digest = strings.ReplaceAll(digest, ":", "-")
|
||||||
|
path := filepath.Join(envconfig.Models(), "skills", digest)
|
||||||
|
dirPath := filepath.Dir(path)
|
||||||
|
if digest == "" {
|
||||||
|
dirPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dirPath, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: ensure path elements are traversable", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractSkillBlob extracts a skill tar.gz blob to the skills cache.
|
||||||
|
// The blob is expected to be at the blobs path for the given digest.
|
||||||
|
// Returns the path to the extracted skill directory.
|
||||||
|
func ExtractSkillBlob(digest string) (string, error) {
|
||||||
|
// Get the blob path
|
||||||
|
blobPath, err := GetBlobsPath(digest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getting blob path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the extraction path
|
||||||
|
skillPath, err := GetSkillsPath(digest)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getting skill path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already extracted
|
||||||
|
if _, err := os.Stat(filepath.Join(skillPath, "SKILL.md")); err == nil {
|
||||||
|
return skillPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the blob
|
||||||
|
f, err := os.Open(blobPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("opening blob: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Create gzip reader
|
||||||
|
gzr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating gzip reader: %w", err)
|
||||||
|
}
|
||||||
|
defer gzr.Close()
|
||||||
|
|
||||||
|
// Create tar reader
|
||||||
|
tr := tar.NewReader(gzr)
|
||||||
|
|
||||||
|
// Create the skill directory
|
||||||
|
if err := os.MkdirAll(skillPath, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating skill directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract files
|
||||||
|
for {
|
||||||
|
header, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("reading tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the name and ensure it doesn't escape the target directory
|
||||||
|
name := filepath.Clean(header.Name)
|
||||||
|
if strings.HasPrefix(name, "..") {
|
||||||
|
return "", fmt.Errorf("invalid path in archive: %s", header.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
target := filepath.Join(skillPath, name)
|
||||||
|
|
||||||
|
// Verify the target is within skillPath
|
||||||
|
if !strings.HasPrefix(target, filepath.Clean(skillPath)+string(os.PathSeparator)) && target != filepath.Clean(skillPath) {
|
||||||
|
return "", fmt.Errorf("path escapes skill directory: %s", header.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating directory: %w", err)
|
||||||
|
}
|
||||||
|
case tar.TypeReg:
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("creating parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("creating file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(outFile, tr); err != nil {
|
||||||
|
outFile.Close()
|
||||||
|
return "", fmt.Errorf("writing file: %w", err)
|
||||||
|
}
|
||||||
|
outFile.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSkillLayer creates a skill layer from a local directory.
|
||||||
|
// The directory must contain a SKILL.md file.
|
||||||
|
// Returns the created layer.
|
||||||
|
func CreateSkillLayer(skillDir string) (Layer, error) {
|
||||||
|
// Verify SKILL.md exists
|
||||||
|
skillMdPath := filepath.Join(skillDir, "SKILL.md")
|
||||||
|
if _, err := os.Stat(skillMdPath); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("skill directory must contain SKILL.md: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file for the tar.gz
|
||||||
|
blobsPath, err := GetBlobsPath("")
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("getting blobs path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp(blobsPath, "skill-*.tar.gz")
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating temp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
defer func() {
|
||||||
|
tmpFile.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Create gzip writer
|
||||||
|
gzw := gzip.NewWriter(tmpFile)
|
||||||
|
defer gzw.Close()
|
||||||
|
|
||||||
|
// Create tar writer
|
||||||
|
tw := tar.NewWriter(gzw)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
// Walk the skill directory and add files to tar
|
||||||
|
err = filepath.Walk(skillDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(skillDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the root directory itself
|
||||||
|
if relPath == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header, err := tar.FileInfoHeader(info, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
header.Name = relPath
|
||||||
|
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file contents if it's a regular file
|
||||||
|
if !info.IsDir() {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(tw, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating tar archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close writers to flush
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing tar writer: %w", err)
|
||||||
|
}
|
||||||
|
if err := gzw.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing gzip writer: %w", err)
|
||||||
|
}
|
||||||
|
if err := tmpFile.Close(); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("closing temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the temp file for reading
|
||||||
|
tmpFile, err = os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("reopening temp file: %w", err)
|
||||||
|
}
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
// Create the layer (this will compute the digest and move to blobs)
|
||||||
|
layer, err := NewLayer(tmpFile, MediaTypeSkill)
|
||||||
|
if err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("creating layer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the skill to the cache so it's ready to use
|
||||||
|
if _, err := ExtractSkillBlob(layer.Digest); err != nil {
|
||||||
|
return Layer{}, fmt.Errorf("extracting skill: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return layer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLocalSkillPath checks if a skill reference looks like a local path.
|
||||||
|
// Local paths are explicitly prefixed with /, ./, ../, or ~.
|
||||||
|
// Registry references like "skill/calculator:1.0.0" should NOT be treated as local paths.
|
||||||
|
func IsLocalSkillPath(name string) bool {
|
||||||
|
// Local paths are explicitly indicated by path prefixes
|
||||||
|
return strings.HasPrefix(name, "/") ||
|
||||||
|
strings.HasPrefix(name, "./") ||
|
||||||
|
strings.HasPrefix(name, "../") ||
|
||||||
|
strings.HasPrefix(name, "~")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillNamespace is the namespace used for standalone skills in the registry.
|
||||||
|
const SkillNamespace = "skill"
|
||||||
|
|
||||||
|
// IsSkillReference checks if a name refers to a skill (has skill/ prefix).
|
||||||
|
func IsSkillReference(name string) bool {
|
||||||
|
// Check for skill/ prefix (handles both "skill/foo" and "registry/skill/foo")
|
||||||
|
name = strings.ReplaceAll(name, string(os.PathSeparator), "/")
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
|
||||||
|
// skill/name or skill/name:tag
|
||||||
|
if len(parts) >= 1 && parts[0] == SkillNamespace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// namespace/skill/name (e.g., myuser/skill/calc) - not a skill ref
|
||||||
|
// registry/skill/name (e.g., registry.ollama.ai/skill/calc)
|
||||||
|
if len(parts) >= 2 && parts[1] == SkillNamespace {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSkillName parses a skill reference string into a model.Name.
|
||||||
|
// The Kind field is set to "skill".
|
||||||
|
// Examples:
|
||||||
|
// - "calculator" -> library/skill/calculator:latest
|
||||||
|
// - "myname/calculator" -> myname/skill/calculator:latest
|
||||||
|
// - "myname/skill/calculator:1.0.0" -> myname/skill/calculator:1.0.0
|
||||||
|
func ParseSkillName(name string) model.Name {
|
||||||
|
// Use the standard parser which now handles Kind
|
||||||
|
n := model.ParseName(name)
|
||||||
|
|
||||||
|
// If Kind wasn't set (old format without skill/), set it
|
||||||
|
if n.Kind == "" {
|
||||||
|
n.Kind = SkillNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillDisplayName returns a user-friendly display name for a skill.
|
||||||
|
func SkillDisplayName(n model.Name) string {
|
||||||
|
return n.DisplayShortest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSkillManifestPath returns the path to the skill manifest file.
|
||||||
|
// Uses the 5-part structure: host/namespace/kind/model/tag
|
||||||
|
func GetSkillManifestPath(n model.Name) (string, error) {
|
||||||
|
if n.Model == "" {
|
||||||
|
return "", fmt.Errorf("skill name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Kind is set
|
||||||
|
if n.Kind == "" {
|
||||||
|
n.Kind = SkillNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(
|
||||||
|
envconfig.Models(),
|
||||||
|
"manifests",
|
||||||
|
n.Filepath(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -1,5 +1,29 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
|
// SkillRef represents a reference to a skill, either by local path or by registry digest.
|
||||||
|
type SkillRef struct {
|
||||||
|
// Name is the local path (for development) or registry name (e.g., "skill/calculator:1.0.0")
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// Digest is the content-addressable digest of the skill blob (e.g., "sha256:abc123...")
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCPRef represents a reference to an MCP (Model Context Protocol) server.
|
||||||
|
type MCPRef struct {
|
||||||
|
// Name is the identifier for the MCP server (used for tool namespacing)
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
// Digest is the content-addressable digest of the bundled MCP server blob
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
// Command is the executable to run (e.g., "uv", "node", "python3")
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
// Args are the arguments to pass to the command
|
||||||
|
Args []string `json:"args,omitempty"`
|
||||||
|
// Env is optional environment variables for the MCP server
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
// Type is the transport type (currently only "stdio" is supported)
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigV2 represents the configuration metadata for a model.
|
// ConfigV2 represents the configuration metadata for a model.
|
||||||
type ConfigV2 struct {
|
type ConfigV2 struct {
|
||||||
ModelFormat string `json:"model_format"`
|
ModelFormat string `json:"model_format"`
|
||||||
@@ -20,6 +44,12 @@ type ConfigV2 struct {
|
|||||||
EmbedLen int `json:"embedding_length,omitempty"`
|
EmbedLen int `json:"embedding_length,omitempty"`
|
||||||
BaseName string `json:"base_name,omitempty"`
|
BaseName string `json:"base_name,omitempty"`
|
||||||
|
|
||||||
|
// agent-specific fields
|
||||||
|
Skills []SkillRef `json:"skills,omitempty"`
|
||||||
|
MCPs []MCPRef `json:"mcps,omitempty"`
|
||||||
|
AgentType string `json:"agent_type,omitempty"`
|
||||||
|
Entrypoint string `json:"entrypoint,omitempty"`
|
||||||
|
|
||||||
// required by spec
|
// required by spec
|
||||||
Architecture string `json:"architecture"`
|
Architecture string `json:"architecture"`
|
||||||
OS string `json:"os"`
|
OS string `json:"os"`
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type partKind int
|
|||||||
const (
|
const (
|
||||||
kindHost partKind = iota
|
kindHost partKind = iota
|
||||||
kindNamespace
|
kindNamespace
|
||||||
|
kindKind
|
||||||
kindModel
|
kindModel
|
||||||
kindTag
|
kindTag
|
||||||
kindDigest
|
kindDigest
|
||||||
@@ -70,6 +71,8 @@ func (k partKind) String() string {
|
|||||||
return "host"
|
return "host"
|
||||||
case kindNamespace:
|
case kindNamespace:
|
||||||
return "namespace"
|
return "namespace"
|
||||||
|
case kindKind:
|
||||||
|
return "kind"
|
||||||
case kindModel:
|
case kindModel:
|
||||||
return "model"
|
return "model"
|
||||||
case kindTag:
|
case kindTag:
|
||||||
@@ -89,6 +92,7 @@ func (k partKind) String() string {
|
|||||||
type Name struct {
|
type Name struct {
|
||||||
Host string
|
Host string
|
||||||
Namespace string
|
Namespace string
|
||||||
|
Kind string // Optional: "skill", "agent", or empty for models
|
||||||
Model string
|
Model string
|
||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
@@ -97,34 +101,27 @@ type Name struct {
|
|||||||
// format of a valid name string is:
|
// format of a valid name string is:
|
||||||
//
|
//
|
||||||
// s:
|
// s:
|
||||||
// { host } "/" { namespace } "/" { model } ":" { tag } "@" { digest }
|
// { host } "/" { namespace } "/" { kind } "/" { model } ":" { tag }
|
||||||
// { host } "/" { namespace } "/" { model } ":" { tag }
|
// { host } "/" { namespace } "/" { model } ":" { tag }
|
||||||
// { host } "/" { namespace } "/" { model } "@" { digest }
|
// { namespace } "/" { kind } "/" { model } ":" { tag }
|
||||||
// { host } "/" { namespace } "/" { model }
|
|
||||||
// { namespace } "/" { model } ":" { tag } "@" { digest }
|
|
||||||
// { namespace } "/" { model } ":" { tag }
|
// { namespace } "/" { model } ":" { tag }
|
||||||
// { namespace } "/" { model } "@" { digest }
|
|
||||||
// { namespace } "/" { model }
|
|
||||||
// { model } ":" { tag } "@" { digest }
|
|
||||||
// { model } ":" { tag }
|
// { model } ":" { tag }
|
||||||
// { model } "@" { digest }
|
|
||||||
// { model }
|
// { model }
|
||||||
// "@" { digest }
|
|
||||||
// host:
|
// host:
|
||||||
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." | ":" }*
|
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." | ":" }*
|
||||||
// length: [1, 350]
|
// length: [1, 350]
|
||||||
// namespace:
|
// namespace:
|
||||||
// pattern: { alphanum | "_" } { alphanum | "-" | "_" }*
|
// pattern: { alphanum | "_" } { alphanum | "-" | "_" }*
|
||||||
// length: [1, 80]
|
// length: [1, 80]
|
||||||
|
// kind:
|
||||||
|
// pattern: "skill" | "agent" | "" (empty for models)
|
||||||
|
// length: [0, 80]
|
||||||
// model:
|
// model:
|
||||||
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
|
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
|
||||||
// length: [1, 80]
|
// length: [1, 80]
|
||||||
// tag:
|
// tag:
|
||||||
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
|
// pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }*
|
||||||
// length: [1, 80]
|
// length: [1, 80]
|
||||||
// digest:
|
|
||||||
// pattern: { alphanum | "_" } { alphanum | "-" | ":" }*
|
|
||||||
// length: [1, 80]
|
|
||||||
//
|
//
|
||||||
// Most users should use [ParseName] instead, unless need to support
|
// Most users should use [ParseName] instead, unless need to support
|
||||||
// different defaults than DefaultName.
|
// different defaults than DefaultName.
|
||||||
@@ -136,6 +133,13 @@ func ParseName(s string) Name {
|
|||||||
return Merge(ParseNameBare(s), DefaultName())
|
return Merge(ParseNameBare(s), DefaultName())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidKinds are the allowed values for the Kind field
|
||||||
|
var ValidKinds = map[string]bool{
|
||||||
|
"skill": true,
|
||||||
|
"agent": true,
|
||||||
|
"mcp": true,
|
||||||
|
}
|
||||||
|
|
||||||
// ParseNameBare parses s as a name string and returns a Name. No merge with
|
// ParseNameBare parses s as a name string and returns a Name. No merge with
|
||||||
// [DefaultName] is performed.
|
// [DefaultName] is performed.
|
||||||
func ParseNameBare(s string) Name {
|
func ParseNameBare(s string) Name {
|
||||||
@@ -153,6 +157,30 @@ func ParseNameBare(s string) Name {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s, n.Kind, promised = cutPromised(s, "/")
|
||||||
|
if !promised {
|
||||||
|
// Only 2 parts: namespace/model - what we parsed as Kind is actually Namespace
|
||||||
|
n.Namespace = n.Kind
|
||||||
|
n.Kind = ""
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if what we parsed as Kind is actually a valid kind value
|
||||||
|
if !ValidKinds[n.Kind] {
|
||||||
|
// Not a valid kind - this is the old 3-part format: host/namespace/model
|
||||||
|
// Shift: Kind -> Namespace, s -> Host
|
||||||
|
n.Namespace = n.Kind
|
||||||
|
n.Kind = ""
|
||||||
|
|
||||||
|
scheme, host, ok := strings.Cut(s, "://")
|
||||||
|
if !ok {
|
||||||
|
host = scheme
|
||||||
|
}
|
||||||
|
n.Host = host
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid kind found - continue parsing for namespace and optional host
|
||||||
s, n.Namespace, promised = cutPromised(s, "/")
|
s, n.Namespace, promised = cutPromised(s, "/")
|
||||||
if !promised {
|
if !promised {
|
||||||
n.Namespace = s
|
n.Namespace = s
|
||||||
@@ -168,20 +196,32 @@ func ParseNameBare(s string) Name {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseNameFromFilepath parses a 4-part filepath as a Name. The parts are
|
// ParseNameFromFilepath parses a 4 or 5-part filepath as a Name. The parts are
|
||||||
// expected to be in the form:
|
// expected to be in the form:
|
||||||
//
|
//
|
||||||
// { host } "/" { namespace } "/" { model } "/" { tag }
|
// { host } "/" { namespace } "/" { model } "/" { tag }
|
||||||
|
// { host } "/" { namespace } "/" { kind } "/" { model } "/" { tag }
|
||||||
func ParseNameFromFilepath(s string) (n Name) {
|
func ParseNameFromFilepath(s string) (n Name) {
|
||||||
parts := strings.Split(s, string(filepath.Separator))
|
parts := strings.Split(s, string(filepath.Separator))
|
||||||
if len(parts) != 4 {
|
|
||||||
|
switch len(parts) {
|
||||||
|
case 4:
|
||||||
|
// Old format: host/namespace/model/tag
|
||||||
|
n.Host = parts[0]
|
||||||
|
n.Namespace = parts[1]
|
||||||
|
n.Model = parts[2]
|
||||||
|
n.Tag = parts[3]
|
||||||
|
case 5:
|
||||||
|
// New format: host/namespace/kind/model/tag
|
||||||
|
n.Host = parts[0]
|
||||||
|
n.Namespace = parts[1]
|
||||||
|
n.Kind = parts[2]
|
||||||
|
n.Model = parts[3]
|
||||||
|
n.Tag = parts[4]
|
||||||
|
default:
|
||||||
return Name{}
|
return Name{}
|
||||||
}
|
}
|
||||||
|
|
||||||
n.Host = parts[0]
|
|
||||||
n.Namespace = parts[1]
|
|
||||||
n.Model = parts[2]
|
|
||||||
n.Tag = parts[3]
|
|
||||||
if !n.IsFullyQualified() {
|
if !n.IsFullyQualified() {
|
||||||
return Name{}
|
return Name{}
|
||||||
}
|
}
|
||||||
@@ -189,11 +229,12 @@ func ParseNameFromFilepath(s string) (n Name) {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge merges the host, namespace, and tag parts of the two names,
|
// Merge merges the host, namespace, kind, and tag parts of the two names,
|
||||||
// preferring the non-empty parts of a.
|
// preferring the non-empty parts of a.
|
||||||
func Merge(a, b Name) Name {
|
func Merge(a, b Name) Name {
|
||||||
a.Host = cmp.Or(a.Host, b.Host)
|
a.Host = cmp.Or(a.Host, b.Host)
|
||||||
a.Namespace = cmp.Or(a.Namespace, b.Namespace)
|
a.Namespace = cmp.Or(a.Namespace, b.Namespace)
|
||||||
|
a.Kind = cmp.Or(a.Kind, b.Kind)
|
||||||
a.Tag = cmp.Or(a.Tag, b.Tag)
|
a.Tag = cmp.Or(a.Tag, b.Tag)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
@@ -211,6 +252,10 @@ func (n Name) String() string {
|
|||||||
b.WriteString(n.Namespace)
|
b.WriteString(n.Namespace)
|
||||||
b.WriteByte('/')
|
b.WriteByte('/')
|
||||||
}
|
}
|
||||||
|
if n.Kind != "" {
|
||||||
|
b.WriteString(n.Kind)
|
||||||
|
b.WriteByte('/')
|
||||||
|
}
|
||||||
b.WriteString(n.Model)
|
b.WriteString(n.Model)
|
||||||
if n.Tag != "" {
|
if n.Tag != "" {
|
||||||
b.WriteByte(':')
|
b.WriteByte(':')
|
||||||
@@ -233,6 +278,12 @@ func (n Name) DisplayShortest() string {
|
|||||||
sb.WriteByte('/')
|
sb.WriteByte('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// include kind if present
|
||||||
|
if n.Kind != "" {
|
||||||
|
sb.WriteString(n.Kind)
|
||||||
|
sb.WriteByte('/')
|
||||||
|
}
|
||||||
|
|
||||||
// always include model and tag
|
// always include model and tag
|
||||||
sb.WriteString(n.Model)
|
sb.WriteString(n.Model)
|
||||||
sb.WriteString(":")
|
sb.WriteString(":")
|
||||||
@@ -256,18 +307,23 @@ func (n Name) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IsFullyQualified returns true if all parts of the name are present and
|
// IsFullyQualified returns true if all parts of the name are present and
|
||||||
// valid without the digest.
|
// valid without the digest. Kind is optional and only validated if non-empty.
|
||||||
func (n Name) IsFullyQualified() bool {
|
func (n Name) IsFullyQualified() bool {
|
||||||
parts := []string{
|
if !isValidPart(kindHost, n.Host) {
|
||||||
n.Host,
|
return false
|
||||||
n.Namespace,
|
|
||||||
n.Model,
|
|
||||||
n.Tag,
|
|
||||||
}
|
}
|
||||||
for i, part := range parts {
|
if !isValidPart(kindNamespace, n.Namespace) {
|
||||||
if !isValidPart(partKind(i), part) {
|
return false
|
||||||
return false
|
}
|
||||||
}
|
// Kind is optional - only validate if present
|
||||||
|
if n.Kind != "" && !isValidPart(kindKind, n.Kind) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isValidPart(kindModel, n.Model) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !isValidPart(kindTag, n.Tag) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -276,6 +332,7 @@ func (n Name) IsFullyQualified() bool {
|
|||||||
// host to tag as a directory in the form:
|
// host to tag as a directory in the form:
|
||||||
//
|
//
|
||||||
// {host}/{namespace}/{model}/{tag}
|
// {host}/{namespace}/{model}/{tag}
|
||||||
|
// {host}/{namespace}/{kind}/{model}/{tag}
|
||||||
//
|
//
|
||||||
// It uses the system's filepath separator and ensures the path is clean.
|
// It uses the system's filepath separator and ensures the path is clean.
|
||||||
//
|
//
|
||||||
@@ -285,6 +342,15 @@ func (n Name) Filepath() string {
|
|||||||
if !n.IsFullyQualified() {
|
if !n.IsFullyQualified() {
|
||||||
panic("illegal attempt to get filepath of invalid name")
|
panic("illegal attempt to get filepath of invalid name")
|
||||||
}
|
}
|
||||||
|
if n.Kind != "" {
|
||||||
|
return filepath.Join(
|
||||||
|
n.Host,
|
||||||
|
n.Namespace,
|
||||||
|
n.Kind,
|
||||||
|
n.Model,
|
||||||
|
n.Tag,
|
||||||
|
)
|
||||||
|
}
|
||||||
return filepath.Join(
|
return filepath.Join(
|
||||||
n.Host,
|
n.Host,
|
||||||
n.Namespace,
|
n.Namespace,
|
||||||
@@ -301,6 +367,7 @@ func (n Name) LogValue() slog.Value {
|
|||||||
func (n Name) EqualFold(o Name) bool {
|
func (n Name) EqualFold(o Name) bool {
|
||||||
return strings.EqualFold(n.Host, o.Host) &&
|
return strings.EqualFold(n.Host, o.Host) &&
|
||||||
strings.EqualFold(n.Namespace, o.Namespace) &&
|
strings.EqualFold(n.Namespace, o.Namespace) &&
|
||||||
|
strings.EqualFold(n.Kind, o.Kind) &&
|
||||||
strings.EqualFold(n.Model, o.Model) &&
|
strings.EqualFold(n.Model, o.Model) &&
|
||||||
strings.EqualFold(n.Tag, o.Tag)
|
strings.EqualFold(n.Tag, o.Tag)
|
||||||
}
|
}
|
||||||
@@ -317,6 +384,11 @@ func isValidLen(kind partKind, s string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func isValidPart(kind partKind, s string) bool {
|
func isValidPart(kind partKind, s string) bool {
|
||||||
|
// Kind must be one of the valid values
|
||||||
|
if kind == kindKind {
|
||||||
|
return ValidKinds[s]
|
||||||
|
}
|
||||||
|
|
||||||
if !isValidLen(kind, s) {
|
if !isValidLen(kind, s) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user