Merge remote-tracking branch 'origin/main' into parth-agent-tui-slash-selector
# Conflicts: # cmd/tui/chat/input_test.go
This commit is contained in:
+39
-50
@@ -11,12 +11,12 @@ type ApprovalRequest struct {
|
|||||||
Calls []ApprovalToolCall
|
Calls []ApprovalToolCall
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ApprovalRequest) AddToolCall(id, name string, args map[string]any) {
|
func (r *ApprovalRequest) AddToolCall(id, name, scope string, args map[string]any) {
|
||||||
r.Calls = append(r.Calls, ApprovalToolCall{
|
r.Calls = append(r.Calls, ApprovalToolCall{
|
||||||
ToolCallID: id,
|
ToolCallID: id,
|
||||||
ToolName: name,
|
ToolName: name,
|
||||||
Args: args,
|
Args: args,
|
||||||
ApprovalScope: toolApprovalScope(name, args),
|
ApprovalScope: scope,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,16 +54,18 @@ func (s *ApprovalState) Set(allowAll bool, scopes map[string]bool) {
|
|||||||
s.scopes = cloneApprovalScopes(scopes)
|
s.scopes = cloneApprovalScopes(scopes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApprovalState) SetAllowAll(allowAll bool) {
|
// GrantAll grants blanket approval for all future tool calls.
|
||||||
|
func (s *ApprovalState) GrantAll() {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.allowAll = allowAll
|
s.allowAll = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApprovalState) AllowAll() bool {
|
// AllGranted reports whether blanket approval has been granted.
|
||||||
|
func (s *ApprovalState) AllGranted() bool {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -81,36 +83,41 @@ func (s *ApprovalState) Allows(scope string) bool {
|
|||||||
return s.allowAll || s.scopes[scope]
|
return s.allowAll || s.scopes[scope]
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApprovalState) Apply(result *Approval) {
|
// Apply merges an approval's scopes and allow-all flag into the state. It
|
||||||
|
// returns true if the approval grants permission (allow-all or at least one
|
||||||
|
// scope). It does not mutate the approval; the caller sets Allow based on the
|
||||||
|
// returned value.
|
||||||
|
func (s *ApprovalState) Apply(result *Approval) bool {
|
||||||
if s == nil || result == nil {
|
if s == nil || result == nil {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
granted := false
|
||||||
if result.AllowAll {
|
if result.AllowAll {
|
||||||
result.Allow = true
|
|
||||||
s.allowAll = true
|
s.allowAll = true
|
||||||
|
granted = true
|
||||||
}
|
}
|
||||||
if len(result.AllowScopes) > 0 {
|
if len(result.AllowScopes) > 0 {
|
||||||
result.Allow = true
|
granted = true
|
||||||
if s.scopes == nil {
|
s.grantScopesLocked(result.AllowScopes)
|
||||||
s.scopes = make(map[string]bool, len(result.AllowScopes))
|
|
||||||
}
|
|
||||||
for _, scope := range result.AllowScopes {
|
|
||||||
scope = strings.TrimSpace(scope)
|
|
||||||
if scope != "" {
|
|
||||||
s.scopes[scope] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return granted
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ApprovalState) AllowScopes(scopes []string) {
|
// GrantScopes merges the given scopes into the state.
|
||||||
|
func (s *ApprovalState) GrantScopes(scopes []string) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
s.grantScopesLocked(scopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantScopesLocked adds trimmed, non-empty scopes to the state. Caller must
|
||||||
|
// hold s.mu.
|
||||||
|
func (s *ApprovalState) grantScopesLocked(scopes []string) {
|
||||||
if s.scopes == nil {
|
if s.scopes == nil {
|
||||||
s.scopes = make(map[string]bool, len(scopes))
|
s.scopes = make(map[string]bool, len(scopes))
|
||||||
}
|
}
|
||||||
@@ -136,7 +143,7 @@ func cloneApprovalScopes(src map[string]bool) map[string]bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) needsApproval(tool Tool, name string, args map[string]any) bool {
|
func (s *Session) needsApproval(tool Tool, name string, args map[string]any) bool {
|
||||||
return ToolRequiresApproval(tool, args) && !s.allows(toolApprovalScope(name, args))
|
return ToolRequiresApproval(tool, args) && !s.allows(toolApprovalScope(tool, name, args))
|
||||||
}
|
}
|
||||||
|
|
||||||
// allows reports whether scope is permitted by the session's accumulated approval state.
|
// allows reports whether scope is permitted by the session's accumulated approval state.
|
||||||
@@ -148,8 +155,7 @@ func (s *Session) allows(scope string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// applyApproval merges an approval result into the session's state and marks
|
// applyApproval merges an approval result into the session's state and marks
|
||||||
// the result as allowed when scopes or allow-all were granted. It mutates
|
// the result as allowed when scopes or allow-all were granted.
|
||||||
// result.Allow so the caller can branch on the effective decision.
|
|
||||||
func (s *Session) applyApproval(result *Approval) {
|
func (s *Session) applyApproval(result *Approval) {
|
||||||
if s == nil || result == nil {
|
if s == nil || result == nil {
|
||||||
return
|
return
|
||||||
@@ -157,11 +163,13 @@ func (s *Session) applyApproval(result *Approval) {
|
|||||||
if s.ApprovalState == nil {
|
if s.ApprovalState == nil {
|
||||||
s.ApprovalState = &ApprovalState{}
|
s.ApprovalState = &ApprovalState{}
|
||||||
}
|
}
|
||||||
s.ApprovalState.Apply(result)
|
if s.ApprovalState.Apply(result) {
|
||||||
|
result.Allow = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (Approval, error) {
|
func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (Approval, error) {
|
||||||
if s == nil || len(req.Calls) == 0 || (s.ApprovalState != nil && s.ApprovalState.AllowAll()) {
|
if s == nil || len(req.Calls) == 0 || (s.ApprovalState != nil && s.ApprovalState.AllGranted()) {
|
||||||
return Approval{Allow: true}, nil
|
return Approval{Allow: true}, nil
|
||||||
}
|
}
|
||||||
if s.ApprovalPrompter == nil {
|
if s.ApprovalPrompter == nil {
|
||||||
@@ -179,31 +187,12 @@ func (s *Session) authorizeToolCalls(ctx context.Context, req ApprovalRequest) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// toolApprovalScope returns the approval scope key for a tool invocation.
|
// toolApprovalScope returns the approval scope key for a tool invocation.
|
||||||
//
|
// If the tool implements ScopedTool, its ApprovalScope method determines the
|
||||||
// For shell tools (bash/powershell) the scope is "<tool>\x00<command>": the
|
// scope (e.g. shell tools scope to "<tool>\x00<command>"). Otherwise the scope
|
||||||
// exact, trimmed command byte string. "Always allow this command" therefore
|
// is the trimmed tool name.
|
||||||
// matches ONLY that precise string — any whitespace, quoting, or casing
|
func toolApprovalScope(tool Tool, toolName string, args map[string]any) string {
|
||||||
// variant, or any command that is a superset of the approved one, will
|
if scoped, ok := tool.(ScopedTool); ok {
|
||||||
// re-prompt. The NUL separator is safe because a shell command string cannot
|
return scoped.ApprovalScope(args)
|
||||||
// contain a literal NUL. For all other tools the scope is the tool name.
|
|
||||||
func toolApprovalScope(toolName string, args map[string]any) string {
|
|
||||||
toolName = strings.TrimSpace(toolName)
|
|
||||||
if isShellApprovalTool(toolName) {
|
|
||||||
if command, ok := stringArg(args, "command"); ok {
|
|
||||||
command = strings.TrimSpace(command)
|
|
||||||
if command != "" {
|
|
||||||
return toolName + "\x00" + command
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return toolName
|
return strings.TrimSpace(toolName)
|
||||||
}
|
|
||||||
|
|
||||||
func isShellApprovalTool(name string) bool {
|
|
||||||
return name == "bash" || name == "powershell"
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringArg(args map[string]any, key string) (string, bool) {
|
|
||||||
value, ok := args[key].(string)
|
|
||||||
return value, ok
|
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-15
@@ -1,28 +1,68 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
func TestApprovalRequestScopesShellCommandsToExactCommand(t *testing.T) {
|
"github.com/ollama/ollama/api"
|
||||||
req := ApprovalRequest{}
|
)
|
||||||
req.AddToolCall("call-1", "bash", map[string]any{"command": " pwd "})
|
|
||||||
req.AddToolCall("call-2", "powershell", map[string]any{"command": "Get-ChildItem"})
|
type mockTool struct {
|
||||||
req.AddToolCall("call-3", "edit", map[string]any{"path": "README.md"})
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockTool) Name() string { return m.name }
|
||||||
|
func (m mockTool) Description() string { return "" }
|
||||||
|
func (m mockTool) Schema() api.ToolFunction {
|
||||||
|
return api.ToolFunction{Name: m.name}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockTool) Execute(context.Context, ToolContext, map[string]any) (ToolResult, error) {
|
||||||
|
return ToolResult{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolApprovalScopeUsesScopedTool(t *testing.T) {
|
||||||
|
shellTool := mockScopedTool{
|
||||||
|
mockTool: mockTool{name: "bash"},
|
||||||
|
scope: func(args map[string]any) string {
|
||||||
|
if cmd, ok := args["command"].(string); ok {
|
||||||
|
cmd = strings.TrimSpace(cmd)
|
||||||
|
if cmd != "" {
|
||||||
|
return "bash\x00" + cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "bash"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plainTool := mockTool{name: "edit"}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
index int
|
tool Tool
|
||||||
want string
|
name string
|
||||||
|
args map[string]any
|
||||||
|
want string
|
||||||
}{
|
}{
|
||||||
{index: 0, want: "bash\x00pwd"},
|
{shellTool, "bash", map[string]any{"command": " pwd "}, "bash\x00pwd"},
|
||||||
{index: 1, want: "powershell\x00Get-ChildItem"},
|
{shellTool, "bash", map[string]any{"command": "Get-ChildItem"}, "bash\x00Get-ChildItem"},
|
||||||
{index: 2, want: "edit"},
|
{plainTool, "edit", map[string]any{"path": "README.md"}, "edit"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
if got := req.Calls[tt.index].ApprovalScope; got != tt.want {
|
if got := toolApprovalScope(tt.tool, tt.name, tt.args); got != tt.want {
|
||||||
t.Fatalf("call %d approval scope = %q, want %q", tt.index, got, tt.want)
|
t.Fatalf("toolApprovalScope(%q) = %q, want %q", tt.name, got, tt.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mockScopedTool struct {
|
||||||
|
mockTool
|
||||||
|
scope func(args map[string]any) string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m mockScopedTool) ApprovalScope(args map[string]any) string {
|
||||||
|
return m.scope(args)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionApplyApprovalScopes(t *testing.T) {
|
func TestSessionApplyApprovalScopes(t *testing.T) {
|
||||||
session := &Session{}
|
session := &Session{}
|
||||||
result := Approval{AllowScopes: []string{"edit", "bash\x00pwd", " "}}
|
result := Approval{AllowScopes: []string{"edit", "bash\x00pwd", " "}}
|
||||||
@@ -38,7 +78,7 @@ func TestSessionApplyApprovalScopes(t *testing.T) {
|
|||||||
if session.allows("bash") || session.allows("bash\x00ls") {
|
if session.allows("bash") || session.allows("bash\x00ls") {
|
||||||
t.Fatal("shell approval was too broad")
|
t.Fatal("shell approval was too broad")
|
||||||
}
|
}
|
||||||
if session.ApprovalState.AllowAll() {
|
if session.ApprovalState.AllGranted() {
|
||||||
t.Fatal("allow all = true, want false for scoped approval")
|
t.Fatal("allow all = true, want false for scoped approval")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +90,6 @@ func TestSessionApplyApprovalAllowAll(t *testing.T) {
|
|||||||
session.applyApproval(&result)
|
session.applyApproval(&result)
|
||||||
|
|
||||||
if !result.Allow || !session.allows("anything") {
|
if !result.Allow || !session.allows("anything") {
|
||||||
t.Fatalf("allow all = %v result = %#v, want allow all", session.ApprovalState.AllowAll(), result)
|
t.Fatalf("allow all = %v result = %#v, want allow all", session.ApprovalState.AllGranted(), result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-22
@@ -26,14 +26,25 @@ const (
|
|||||||
defaultCompactionThreshold = 0.8
|
defaultCompactionThreshold = 0.8
|
||||||
compactOnlySummaryContextTokens = 16000
|
compactOnlySummaryContextTokens = 16000
|
||||||
|
|
||||||
maxCompactionSummaryBytes = 16 * 1024
|
maxCompactionSummaryRunes = 16 * 1024
|
||||||
compactionSummaryTruncated = "\n\n[summary truncated]"
|
|
||||||
|
|
||||||
compactionSystemPrompt = "Summarize the archived part of an Ollama agent conversation. Preserve user goals, decisions, files, commands, tool results, and unresolved tasks needed to continue. Omit private reasoning and return only the summary."
|
compactionSystemPrompt = "Summarize the archived part of an Ollama agent conversation. Preserve user goals, decisions, files, commands, tool results, and unresolved tasks needed to continue. Omit private reasoning and return only the summary."
|
||||||
)
|
)
|
||||||
|
|
||||||
type Compactor interface {
|
type Compactor interface {
|
||||||
MaybeCompact(context.Context, CompactionRequest) (CompactionResult, error)
|
MaybeCompact(context.Context, CompactionRequest) (CompactionResult, error)
|
||||||
|
|
||||||
|
// ContextWindowTokens returns the effective context window size in
|
||||||
|
// tokens, resolving runtime options against configured defaults.
|
||||||
|
ContextWindowTokens(options map[string]any) int
|
||||||
|
|
||||||
|
// Threshold returns the compaction threshold as a fraction of the
|
||||||
|
// context window (e.g. 0.8 means compact at 80% capacity).
|
||||||
|
Threshold() float64
|
||||||
|
|
||||||
|
// ShouldCompact reports whether a compaction should run and returns the
|
||||||
|
// trigger reason. An empty trigger means compaction is not needed.
|
||||||
|
ShouldCompact(req CompactionRequest) (trigger string, should bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
type CompactionOptions struct {
|
type CompactionOptions struct {
|
||||||
@@ -146,6 +157,48 @@ func (c *SimpleCompactor) contextWindowTokens(options map[string]any) int {
|
|||||||
return ResolveContextWindowTokens(options, c.Options.ContextWindowTokens)
|
return ResolveContextWindowTokens(options, c.Options.ContextWindowTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContextWindowTokens resolves the effective context window from runtime
|
||||||
|
// options or configured defaults. Satisfies the Compactor interface.
|
||||||
|
func (c *SimpleCompactor) ContextWindowTokens(options map[string]any) int {
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.contextWindowTokens(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SimpleCompactor) threshold() float64 {
|
||||||
|
return ResolveCompactionThreshold(c.Options.Threshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold returns the configured compaction threshold fraction. Satisfies
|
||||||
|
// the Compactor interface.
|
||||||
|
func (c *SimpleCompactor) Threshold() float64 {
|
||||||
|
if c == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.threshold()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldCompact reports whether compaction is due and the trigger reason.
|
||||||
|
// Satisfies the Compactor interface.
|
||||||
|
func (c *SimpleCompactor) ShouldCompact(req CompactionRequest) (string, bool) {
|
||||||
|
if c == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if req.Force {
|
||||||
|
return "force", true
|
||||||
|
}
|
||||||
|
if c.shouldCompact(req) {
|
||||||
|
contextWindow := c.contextWindowTokens(req.Options)
|
||||||
|
threshold := int(float64(contextWindow) * c.threshold())
|
||||||
|
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
||||||
|
return "prompt_eval", true
|
||||||
|
}
|
||||||
|
return "estimate", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
||||||
contextWindow := c.contextWindowTokens(options)
|
contextWindow := c.contextWindowTokens(options)
|
||||||
if contextWindow > 0 && contextWindow < compactOnlySummaryContextTokens {
|
if contextWindow > 0 && contextWindow < compactOnlySummaryContextTokens {
|
||||||
@@ -157,10 +210,6 @@ func (c *SimpleCompactor) keepUserTurns(options map[string]any) int {
|
|||||||
return defaultCompactionKeepUserTurns
|
return defaultCompactionKeepUserTurns
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *SimpleCompactor) threshold() float64 {
|
|
||||||
return ResolveCompactionThreshold(c.Options.Threshold)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveContextWindowTokens(options map[string]any, configured int) int {
|
func ResolveContextWindowTokens(options map[string]any, configured int) int {
|
||||||
if n := intOption(options, "num_ctx"); n > 0 {
|
if n := intOption(options, "num_ctx"); n > 0 {
|
||||||
return n
|
return n
|
||||||
@@ -301,21 +350,10 @@ func (c *SimpleCompactor) compactionPromptBodyBudgetTokens(options map[string]an
|
|||||||
}
|
}
|
||||||
|
|
||||||
func truncateCompactionSummary(summary string) string {
|
func truncateCompactionSummary(summary string) string {
|
||||||
if len(summary) <= maxCompactionSummaryBytes {
|
return Truncate(summary, TruncateConfig{
|
||||||
return summary
|
MaxRunes: maxCompactionSummaryRunes,
|
||||||
}
|
Label: "summary",
|
||||||
limit := maxCompactionSummaryBytes - len(compactionSummaryTruncated)
|
})
|
||||||
if limit < 0 {
|
|
||||||
limit = 0
|
|
||||||
}
|
|
||||||
var b strings.Builder
|
|
||||||
for _, r := range summary {
|
|
||||||
if b.Len()+len(string(r)) > limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(b.String()) + compactionSummaryTruncated
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateCompactionTokens(text string) int {
|
func estimateCompactionTokens(text string) int {
|
||||||
@@ -323,7 +361,7 @@ func estimateCompactionTokens(text string) int {
|
|||||||
if text == "" {
|
if text == "" {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return max(1, (len([]rune(text))+3)/4)
|
return ApproximateTokens(len([]rune(text)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func estimateMessagesTokens(messages []api.Message) int {
|
func estimateMessagesTokens(messages []api.Message) int {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ func TestSimpleCompactorAddsContinueTaskInstructionOnlyToToolResult(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
||||||
longSummary := strings.Repeat("x", maxCompactionSummaryBytes+1024)
|
longSummary := strings.Repeat("x", maxCompactionSummaryRunes+1024)
|
||||||
client := &fakeClient{
|
client := &fakeClient{
|
||||||
responses: [][]api.ChatResponse{{
|
responses: [][]api.ChatResponse{{
|
||||||
{Message: api.Message{Role: "assistant", Content: longSummary}},
|
{Message: api.Message{Role: "assistant", Content: longSummary}},
|
||||||
@@ -214,13 +214,13 @@ func TestSimpleCompactorTruncatesOversizedSummary(t *testing.T) {
|
|||||||
if !result.Compacted {
|
if !result.Compacted {
|
||||||
t.Fatal("expected compaction")
|
t.Fatal("expected compaction")
|
||||||
}
|
}
|
||||||
if len(result.Summary) > maxCompactionSummaryBytes {
|
if runeCount := len([]rune(result.Summary)); runeCount > maxCompactionSummaryRunes+200 {
|
||||||
t.Fatalf("summary bytes = %d, want <= %d", len(result.Summary), maxCompactionSummaryBytes)
|
t.Fatalf("summary runes = %d, want <= %d (plus marker)", runeCount, maxCompactionSummaryRunes)
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(result.Summary, compactionSummaryTruncated) {
|
if !strings.Contains(result.Summary, "[summary truncated:") {
|
||||||
t.Fatalf("summary missing truncation marker")
|
t.Fatalf("summary missing truncation marker: %q", result.Summary)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.Messages[1].Content, compactionSummaryTruncated) {
|
if !strings.Contains(result.Messages[1].Content, "[summary truncated:") {
|
||||||
t.Fatalf("compacted message missing truncation marker: %#v", result.Messages)
|
t.Fatalf("compacted message missing truncation marker: %#v", result.Messages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-19
@@ -2,6 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
)
|
)
|
||||||
@@ -22,22 +23,60 @@ const (
|
|||||||
EventError EventType = "error"
|
EventError EventType = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ToolStatus is the typed lifecycle state for a tool call, carried on
|
||||||
|
// Event.ToolStatus for tool events.
|
||||||
|
type ToolStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ToolStatusRunning ToolStatus = "running"
|
||||||
|
ToolStatusDone ToolStatus = "done"
|
||||||
|
ToolStatusFailed ToolStatus = "failed"
|
||||||
|
ToolStatusDenied ToolStatus = "denied"
|
||||||
|
ToolStatusDisabled ToolStatus = "disabled"
|
||||||
|
ToolStatusSkipped ToolStatus = "skipped"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunStatus is the typed terminal outcome of a run, carried on Event.Status for
|
||||||
|
// run_finished events.
|
||||||
|
type RunStatus string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RunStatusDone RunStatus = "done"
|
||||||
|
RunStatusDenied RunStatus = "denied"
|
||||||
|
RunStatusCanceled RunStatus = "canceled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompactionTrigger is the typed reason a compaction ran or was attempted,
|
||||||
|
// carried on Event.CompactionTrigger for compaction events.
|
||||||
|
type CompactionTrigger string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CompactionTriggerForce CompactionTrigger = "force"
|
||||||
|
CompactionTriggerPromptEval CompactionTrigger = "prompt_eval"
|
||||||
|
CompactionTriggerEstimate CompactionTrigger = "estimate"
|
||||||
|
CompactionTriggerToolOutput CompactionTrigger = "tool_output"
|
||||||
|
CompactionTriggerError CompactionTrigger = "error"
|
||||||
|
CompactionTriggerDue CompactionTrigger = "due"
|
||||||
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventType `json:"type"`
|
Type EventType `json:"type"`
|
||||||
RunID string `json:"runId,omitempty"`
|
RunID string `json:"runId,omitempty"`
|
||||||
ChatID string `json:"chatId,omitempty"`
|
ChatID string `json:"chatId,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status RunStatus `json:"status,omitempty"`
|
||||||
ToolCallID string `json:"toolCallId,omitempty"`
|
ToolStatus ToolStatus `json:"toolStatus,omitempty"`
|
||||||
ToolName string `json:"toolName,omitempty"`
|
CompactionTrigger CompactionTrigger `json:"compactionTrigger,omitempty"`
|
||||||
WorkingDir string `json:"workingDir,omitempty"`
|
ToolCallID string `json:"toolCallId,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
ToolName string `json:"toolName,omitempty"`
|
||||||
Thinking string `json:"thinking,omitempty"`
|
WorkingDir string `json:"workingDir,omitempty"`
|
||||||
ToolCalls []api.ToolCall `json:"toolCalls,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Messages []api.Message `json:"messages,omitempty"`
|
Thinking string `json:"thinking,omitempty"`
|
||||||
Args map[string]any `json:"args,omitempty"`
|
ToolCalls []api.ToolCall `json:"toolCalls,omitempty"`
|
||||||
Tokens int `json:"tokens,omitempty"`
|
Messages []api.Message `json:"messages,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Args map[string]any `json:"args,omitempty"`
|
||||||
|
Tokens int `json:"tokens,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventSink interface {
|
type EventSink interface {
|
||||||
@@ -53,20 +92,79 @@ func (fn EventSinkFunc) Emit(event Event) error {
|
|||||||
return fn(event)
|
return fn(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventMetadata carries the run identification fields shared by all events.
|
||||||
|
type eventMetadata struct {
|
||||||
|
runID string
|
||||||
|
chatID string
|
||||||
|
model string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEventMetadata(runID string, opts RunOptions) eventMetadata {
|
||||||
|
return eventMetadata{runID: runID, chatID: opts.ChatID, model: opts.Model}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessageDelta(m eventMetadata, content string) Event {
|
||||||
|
return Event{Type: EventMessageDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThinkingDelta(m eventMetadata, thinking string) Event {
|
||||||
|
return Event{Type: EventThinkingDelta, RunID: m.runID, ChatID: m.chatID, Model: m.model, Thinking: thinking}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolCallDetected(m eventMetadata, calls []api.ToolCall) Event {
|
||||||
|
return Event{Type: EventToolCallDetected, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolCalls: calls}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolStarted(m eventMetadata, callID, toolName, workingDir string, args map[string]any) Event {
|
||||||
|
return Event{Type: EventToolStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: ToolStatusRunning, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToolFinished(m eventMetadata, status ToolStatus, callID, toolName, workingDir string, args map[string]any, content, errMsg string) Event {
|
||||||
|
ev := Event{Type: EventToolFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, ToolStatus: status, ToolCallID: callID, ToolName: toolName, WorkingDir: workingDir, Args: args, Content: content}
|
||||||
|
if errMsg != "" {
|
||||||
|
ev.Error = errMsg
|
||||||
|
}
|
||||||
|
return ev
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunFinished(m eventMetadata, status RunStatus) Event {
|
||||||
|
return Event{Type: EventRunFinished, RunID: m.runID, ChatID: m.chatID, Model: m.model, Status: status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newErrorEvent(m eventMetadata, errMsg string) Event {
|
||||||
|
return Event{Type: EventError, RunID: m.runID, ChatID: m.chatID, Model: m.model, Error: errMsg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompactionProgress(m eventMetadata, tokens int) Event {
|
||||||
|
return Event{Type: EventCompactionProgress, RunID: m.runID, ChatID: m.chatID, Model: m.model, Tokens: tokens}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompactionStarted(m eventMetadata, trigger CompactionTrigger) Event {
|
||||||
|
return Event{Type: EventCompactionStarted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompactionSkipped(m eventMetadata, trigger CompactionTrigger, content string) Event {
|
||||||
|
return Event{Type: EventCompactionSkipped, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCompacted(m eventMetadata, messages []api.Message, trigger CompactionTrigger, content string) Event {
|
||||||
|
return Event{Type: EventCompacted, RunID: m.runID, ChatID: m.chatID, Model: m.model, CompactionTrigger: trigger, Content: content, Messages: messages}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) emit(event Event) error {
|
func (s *Session) emit(event Event) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var firstErr error
|
var errs []error
|
||||||
for _, sink := range s.EventSinks {
|
for _, sink := range s.EventSinks {
|
||||||
if sink == nil {
|
if sink == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := sink.Emit(event); err != nil && firstErr == nil {
|
if err := sink.Emit(event); err != nil {
|
||||||
firstErr = err
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return firstErr
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) emitIgnoringCanceled(ctx context.Context, event Event) error {
|
func (s *Session) emitIgnoringCanceled(ctx context.Context, event Event) error {
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ type ApprovalRequired interface {
|
|||||||
RequiresApproval(map[string]any) bool
|
RequiresApproval(map[string]any) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScopedTool is implemented by tools that need per-invocation approval
|
||||||
|
// scoping beyond the tool name (e.g. shell commands scoped to the exact
|
||||||
|
// command string). Tools that don't implement this are scoped by name only.
|
||||||
|
type ScopedTool interface {
|
||||||
|
ApprovalScope(args map[string]any) string
|
||||||
|
}
|
||||||
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
tools map[string]Tool
|
tools map[string]Tool
|
||||||
}
|
}
|
||||||
|
|||||||
+251
-175
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
|
"github.com/ollama/ollama/internal/modelref"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ChatClient interface {
|
type ChatClient interface {
|
||||||
@@ -22,6 +23,7 @@ type Session struct {
|
|||||||
Client ChatClient
|
Client ChatClient
|
||||||
EventSinks []EventSink
|
EventSinks []EventSink
|
||||||
Tools *Registry
|
Tools *Registry
|
||||||
|
Skills *SkillCatalog
|
||||||
DisableTools bool
|
DisableTools bool
|
||||||
ApprovalPrompter ApprovalPrompter
|
ApprovalPrompter ApprovalPrompter
|
||||||
ApprovalState *ApprovalState
|
ApprovalState *ApprovalState
|
||||||
@@ -39,9 +41,13 @@ type RunOptions struct {
|
|||||||
Options map[string]any
|
Options map[string]any
|
||||||
Think *api.ThinkValue
|
Think *api.ThinkValue
|
||||||
KeepAlive *api.Duration
|
KeepAlive *api.Duration
|
||||||
// MaxToolRounds limits consecutive model/tool cycles.
|
// SkillName loads a catalog skill as an ordered synthetic tool call/result
|
||||||
// Zero uses the default guard; negative disables the guard for tests or
|
// before the first model request for this run.
|
||||||
// special callers.
|
SkillName string
|
||||||
|
// MaxToolRounds limits consecutive model/tool cycles. A positive value is
|
||||||
|
// an explicit limit. Zero selects the model-specific default: local models
|
||||||
|
// use the default guard and cloud models are unlimited. A negative value
|
||||||
|
// disables the guard for tests or special callers.
|
||||||
MaxToolRounds int
|
MaxToolRounds int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +80,10 @@ type toolBatchResult struct {
|
|||||||
overflows []toolOutputOverflow
|
overflows []toolOutputOverflow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolExecutionStop is the batch-level outcome for a group of tool calls,
|
||||||
|
// distinct from per-call Event.Status values. The values overlap with
|
||||||
|
// runFinish.status ("denied", "canceled") because a denied or canceled
|
||||||
|
// batch also terminates the run with the matching status.
|
||||||
type toolExecutionStop string
|
type toolExecutionStop string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -116,23 +126,23 @@ type runState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type runFinish struct {
|
type runFinish struct {
|
||||||
status string
|
status RunStatus
|
||||||
ignoreCanceled bool
|
ignoreCanceled bool
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *runState) finishDone() {
|
func (st *runState) finishDone() {
|
||||||
st.finish = runFinish{status: "done"}
|
st.finish = runFinish{status: RunStatusDone}
|
||||||
st.phase = runPhaseDone
|
st.phase = runPhaseDone
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *runState) finishDenied() {
|
func (st *runState) finishDenied() {
|
||||||
st.finish = runFinish{status: "denied"}
|
st.finish = runFinish{status: RunStatusDenied}
|
||||||
st.phase = runPhaseDone
|
st.phase = runPhaseDone
|
||||||
}
|
}
|
||||||
|
|
||||||
func (st *runState) finishCanceled() {
|
func (st *runState) finishCanceled() {
|
||||||
st.finish = runFinish{status: "canceled", ignoreCanceled: true}
|
st.finish = runFinish{status: RunStatusCanceled, ignoreCanceled: true}
|
||||||
st.phase = runPhaseDone
|
st.phase = runPhaseDone
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,39 +152,36 @@ func (st *runState) finishError(err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error) {
|
||||||
if s == nil {
|
if err := s.validateRun(opts); err != nil {
|
||||||
return nil, errors.New("nil session")
|
return nil, err
|
||||||
}
|
|
||||||
if s.Client == nil {
|
|
||||||
return nil, errors.New("agent session requires a chat client")
|
|
||||||
}
|
|
||||||
if opts.Model == "" {
|
|
||||||
return nil, errors.New("agent session requires a model")
|
|
||||||
}
|
}
|
||||||
if s.ApprovalState == nil {
|
if s.ApprovalState == nil {
|
||||||
s.ApprovalState = &ApprovalState{}
|
s.ApprovalState = &ApprovalState{}
|
||||||
}
|
}
|
||||||
runID := uuid.NewString()
|
runID := uuid.NewString()
|
||||||
messages := make([]api.Message, 0, len(opts.Messages)+len(opts.NewMessages))
|
messages, err := s.buildRunMessages(ctx, runID, opts)
|
||||||
for _, msg := range opts.Messages {
|
if err != nil {
|
||||||
messages = append(messages, sanitizeMessageForRun(msg))
|
|
||||||
}
|
|
||||||
for _, msg := range opts.NewMessages {
|
|
||||||
msg = sanitizeMessageForRun(msg)
|
|
||||||
messages = append(messages, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
|
||||||
s.emit(Event{Type: EventError, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
activatedSkill, err := s.activateSkill(ctx, runID, opts)
|
||||||
|
if err != nil {
|
||||||
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(activatedSkill) > 0 {
|
||||||
|
messages = append(messages, activatedSkill...)
|
||||||
|
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
||||||
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
st := runState{
|
st := runState{
|
||||||
runID: runID,
|
runID: runID,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
phase: runPhaseModel,
|
phase: runPhaseModel,
|
||||||
messages: messages,
|
messages: messages,
|
||||||
maxToolRounds: resolvedMaxToolRounds(opts.MaxToolRounds),
|
maxToolRounds: resolvedMaxToolRounds(opts.Model, opts.MaxToolRounds),
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
switch st.phase {
|
switch st.phase {
|
||||||
@@ -188,7 +195,7 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
|||||||
}
|
}
|
||||||
case runPhaseCompact:
|
case runPhaseCompact:
|
||||||
if err := s.runCompactionStep(ctx, &st); err != nil {
|
if err := s.runCompactionStep(ctx, &st); err != nil {
|
||||||
return &RunResult{Messages: st.messages, Latest: st.latest, WorkingDir: s.WorkingDir}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
case runPhaseDone:
|
case runPhaseDone:
|
||||||
return s.finishRun(ctx, &st)
|
return s.finishRun(ctx, &st)
|
||||||
@@ -196,8 +203,43 @@ func (s *Session) Run(ctx context.Context, opts RunOptions) (*RunResult, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateRun checks the preconditions for a run.
|
||||||
|
func (s *Session) validateRun(opts RunOptions) error {
|
||||||
|
if s == nil {
|
||||||
|
return errors.New("nil session")
|
||||||
|
}
|
||||||
|
if s.Client == nil {
|
||||||
|
return errors.New("agent session requires a chat client")
|
||||||
|
}
|
||||||
|
if opts.Model == "" {
|
||||||
|
return errors.New("agent session requires a model")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRunMessages sanitizes the provided message history, runs the preflight
|
||||||
|
// prompt-budget check, and returns the initial message list for the run. It
|
||||||
|
// emits an EventError and returns it if the preflight check fails.
|
||||||
|
func (s *Session) buildRunMessages(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
|
||||||
|
messages := make([]api.Message, 0, len(opts.Messages)+len(opts.NewMessages))
|
||||||
|
for _, msg := range opts.Messages {
|
||||||
|
messages = append(messages, sanitizeMessageForRun(msg))
|
||||||
|
}
|
||||||
|
for _, msg := range opts.NewMessages {
|
||||||
|
msg = sanitizeMessageForRun(msg)
|
||||||
|
messages = append(messages, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.checkPreflightPromptBudget(opts, messages); err != nil {
|
||||||
|
s.emit(newErrorEvent(newEventMetadata(runID, opts), err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
||||||
opts := st.opts
|
opts := st.opts
|
||||||
|
meta := newEventMetadata(st.runID, opts)
|
||||||
|
|
||||||
assistant, pendingToolCalls, canceled, err := s.chatRound(ctx, st.runID, opts, st.messages, &st.latest)
|
assistant, pendingToolCalls, canceled, err := s.chatRound(ctx, st.runID, opts, st.messages, &st.latest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -210,7 +252,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
s.emit(newErrorEvent(meta, err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
st.consecutiveModelErrors = 0
|
st.consecutiveModelErrors = 0
|
||||||
@@ -231,7 +273,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|||||||
if canceled {
|
if canceled {
|
||||||
skipped, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, "Tool execution skipped because the run was canceled.")
|
skipped, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, "Tool execution skipped because the run was canceled.")
|
||||||
if skipErr != nil {
|
if skipErr != nil {
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||||
return skipErr
|
return skipErr
|
||||||
}
|
}
|
||||||
st.messages = append(st.messages, skipped...)
|
st.messages = append(st.messages, skipped...)
|
||||||
@@ -242,7 +284,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|||||||
if s.DisableTools {
|
if s.DisableTools {
|
||||||
batch, skipErr := s.disabledToolCalls(ctx, st.runID, opts, st.messages, pendingToolCalls)
|
batch, skipErr := s.disabledToolCalls(ctx, st.runID, opts, st.messages, pendingToolCalls)
|
||||||
if skipErr != nil {
|
if skipErr != nil {
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||||
return skipErr
|
return skipErr
|
||||||
}
|
}
|
||||||
st.messages = append(st.messages, batch.messages...)
|
st.messages = append(st.messages, batch.messages...)
|
||||||
@@ -260,12 +302,12 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|||||||
content := fmt.Sprintf("Tool execution skipped because the max tool-round limit of %d was reached. Send another message to continue.", st.maxToolRounds)
|
content := fmt.Sprintf("Tool execution skipped because the max tool-round limit of %d was reached. Send another message to continue.", st.maxToolRounds)
|
||||||
toolMessages, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, content)
|
toolMessages, skipErr := s.skipToolCalls(ctx, st.runID, opts, pendingToolCalls, content)
|
||||||
if skipErr != nil {
|
if skipErr != nil {
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: skipErr.Error()})
|
s.emit(newErrorEvent(meta, skipErr.Error()))
|
||||||
return skipErr
|
return skipErr
|
||||||
}
|
}
|
||||||
st.messages = append(st.messages, toolMessages...)
|
st.messages = append(st.messages, toolMessages...)
|
||||||
err := fmt.Errorf("tool round limit reached after %d rounds; send another message to continue", st.maxToolRounds)
|
err := fmt.Errorf("tool round limit reached after %d rounds; send another message to continue", st.maxToolRounds)
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
s.emit(newErrorEvent(meta, err.Error()))
|
||||||
st.finishError(err)
|
st.finishError(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -277,7 +319,7 @@ func (s *Session) runModelStep(ctx context.Context, st *runState) error {
|
|||||||
func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
||||||
batch, err := s.executeToolCalls(ctx, st.runID, st.opts, st.messages, st.pendingToolCalls)
|
batch, err := s.executeToolCalls(ctx, st.runID, st.opts, st.messages, st.pendingToolCalls)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: st.opts.ChatID, Model: st.opts.Model, Error: err.Error()})
|
s.emit(newErrorEvent(newEventMetadata(st.runID, st.opts), err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +331,7 @@ func (s *Session) runToolStep(ctx context.Context, st *runState) error {
|
|||||||
|
|
||||||
func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
||||||
opts := st.opts
|
opts := st.opts
|
||||||
|
meta := newEventMetadata(st.runID, opts)
|
||||||
var err error
|
var err error
|
||||||
if st.toolBatch != nil && len(st.toolBatch.overflows) > 0 {
|
if st.toolBatch != nil && len(st.toolBatch.overflows) > 0 {
|
||||||
st.messages, st.compactionSkipNotified, err = s.compactForToolOutputOverflow(ctx, st.runID, opts, st.messages, st.latest, st.assistant, st.toolBatch.messages, st.toolBatch.overflows, st.compactionSkipNotified)
|
st.messages, st.compactionSkipNotified, err = s.compactForToolOutputOverflow(ctx, st.runID, opts, st.messages, st.latest, st.assistant, st.toolBatch.messages, st.toolBatch.overflows, st.compactionSkipNotified)
|
||||||
@@ -296,8 +339,9 @@ func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
|||||||
st.messages, st.compactionSkipNotified, err = s.maybeCompact(ctx, st.runID, opts, st.messages, st.latest, st.compactionSkipNotified)
|
st.messages, st.compactionSkipNotified, err = s.maybeCompact(ctx, st.runID, opts, st.messages, st.latest, st.compactionSkipNotified)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.emit(Event{Type: EventError, RunID: st.runID, ChatID: opts.ChatID, Model: opts.Model, Error: err.Error()})
|
s.emit(newErrorEvent(meta, err.Error()))
|
||||||
return err
|
st.finishError(err)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if st.toolBatch == nil {
|
if st.toolBatch == nil {
|
||||||
@@ -326,7 +370,7 @@ func (s *Session) runCompactionStep(ctx context.Context, st *runState) error {
|
|||||||
|
|
||||||
func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, error) {
|
func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, error) {
|
||||||
if st.finish.status != "" {
|
if st.finish.status != "" {
|
||||||
event := Event{Type: EventRunFinished, RunID: st.runID, ChatID: st.opts.ChatID, Model: st.opts.Model, Status: st.finish.status}
|
event := newRunFinished(newEventMetadata(st.runID, st.opts), st.finish.status)
|
||||||
var err error
|
var err error
|
||||||
if st.finish.ignoreCanceled {
|
if st.finish.ignoreCanceled {
|
||||||
err = s.emitIgnoringCanceled(ctx, event)
|
err = s.emitIgnoringCanceled(ctx, event)
|
||||||
@@ -341,6 +385,7 @@ func (s *Session) finishRun(ctx context.Context, st *runState) (*RunResult, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest *api.ChatResponse) (api.Message, []api.ToolCall, bool, error) {
|
func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions, messages []api.Message, latest *api.ChatResponse) (api.Message, []api.ToolCall, bool, error) {
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
var tools api.Tools
|
var tools api.Tools
|
||||||
if !s.DisableTools {
|
if !s.DisableTools {
|
||||||
tools = s.availableTools()
|
tools = s.availableTools()
|
||||||
@@ -355,21 +400,21 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
|
|||||||
assistant.Role = response.Message.Role
|
assistant.Role = response.Message.Role
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Message.Content == "" && response.Message.Thinking == "" && len(response.Message.ToolCalls) == 0 {
|
if messageEmpty(response.Message) {
|
||||||
*latest = response
|
*latest = response
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Message.Thinking != "" {
|
if response.Message.Thinking != "" {
|
||||||
assistant.Thinking += response.Message.Thinking
|
assistant.Thinking += response.Message.Thinking
|
||||||
if err := s.emit(Event{Type: EventThinkingDelta, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Thinking: response.Message.Thinking}); err != nil {
|
if err := s.emit(newThinkingDelta(meta, response.Message.Thinking)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if response.Message.Content != "" {
|
if response.Message.Content != "" {
|
||||||
assistant.Content += response.Message.Content
|
assistant.Content += response.Message.Content
|
||||||
if err := s.emit(Event{Type: EventMessageDelta, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Content: response.Message.Content}); err != nil {
|
if err := s.emit(newMessageDelta(meta, response.Message.Content)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +422,7 @@ func (s *Session) chatRound(ctx context.Context, runID string, opts RunOptions,
|
|||||||
if len(response.Message.ToolCalls) > 0 {
|
if len(response.Message.ToolCalls) > 0 {
|
||||||
assistant.ToolCalls = append(assistant.ToolCalls, response.Message.ToolCalls...)
|
assistant.ToolCalls = append(assistant.ToolCalls, response.Message.ToolCalls...)
|
||||||
pendingToolCalls = append(pendingToolCalls, response.Message.ToolCalls...)
|
pendingToolCalls = append(pendingToolCalls, response.Message.ToolCalls...)
|
||||||
if err := s.emit(Event{Type: EventToolCallDetected, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, ToolCalls: response.Message.ToolCalls}); err != nil {
|
if err := s.emit(newToolCallDetected(meta, response.Message.ToolCalls)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,10 +470,17 @@ func buildChatRequest(opts RunOptions, messages []api.Message, tools api.Tools)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
batch := toolBatchResult{
|
batch := toolBatchResult{
|
||||||
messages: make([]api.Message, 0, len(calls)),
|
messages: make([]api.Message, 0, len(calls)),
|
||||||
}
|
}
|
||||||
projectedMessages := append([]api.Message(nil), messages...)
|
projectedMessages := append([]api.Message(nil), messages...)
|
||||||
|
// Pre-compute the full-history token estimate once per batch instead of
|
||||||
|
// re-marshaling the entire history for each tool call. Per-call deltas
|
||||||
|
// (tool messages already appended this batch) are tracked in batchTokens
|
||||||
|
// and added to historyTokens for a lightweight running total.
|
||||||
|
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||||
|
batchTokens := 0
|
||||||
|
|
||||||
type plannedToolCall struct {
|
type plannedToolCall struct {
|
||||||
call api.ToolCall
|
call api.ToolCall
|
||||||
@@ -452,7 +504,7 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||||||
workingDir: batchWorkingDir,
|
workingDir: batchWorkingDir,
|
||||||
})
|
})
|
||||||
if ok && s.needsApproval(tool, toolName, args) {
|
if ok && s.needsApproval(tool, toolName, args) {
|
||||||
approvalReq.AddToolCall(call.ID, toolName, args)
|
approvalReq.AddToolCall(call.ID, toolName, toolApprovalScope(tool, toolName, args), args)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,11 +528,12 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||||||
content = "Tool execution denied."
|
content = "Tool execution denied."
|
||||||
}
|
}
|
||||||
for _, plan := range plans {
|
for _, plan := range plans {
|
||||||
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, projectedMessages)
|
msg := s.toolMessageForContext(plan.toolName, plan.call.ID, content, opts, historyTokens+batchTokens)
|
||||||
batch.messages = append(batch.messages, msg)
|
batch.messages = append(batch.messages, msg)
|
||||||
projectedMessages = append(projectedMessages, msg)
|
projectedMessages = append(projectedMessages, msg)
|
||||||
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||||
deniedContent := msg.Content
|
deniedContent := msg.Content
|
||||||
if emitErr := s.emit(Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "denied", ToolCallID: plan.call.ID, ToolName: plan.toolName, Args: plan.args, Content: deniedContent, Error: deniedContent}); emitErr != nil {
|
if emitErr := s.emit(newToolFinished(meta, "denied", plan.call.ID, plan.toolName, "", plan.args, deniedContent, deniedContent)); emitErr != nil {
|
||||||
return toolBatchResult{}, emitErr
|
return toolBatchResult{}, emitErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -504,34 +557,36 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||||||
}
|
}
|
||||||
if plan.tool == nil {
|
if plan.tool == nil {
|
||||||
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
content := fmt.Sprintf("Error: unknown tool: %s", toolName)
|
||||||
msg := s.toolMessageForContext(toolName, call.ID, content, opts, projectedMessages)
|
msg := s.toolMessageForContext(toolName, call.ID, content, opts, historyTokens+batchTokens)
|
||||||
batch.messages = append(batch.messages, msg)
|
batch.messages = append(batch.messages, msg)
|
||||||
projectedMessages = append(projectedMessages, msg)
|
projectedMessages = append(projectedMessages, msg)
|
||||||
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||||
content = msg.Content
|
content = msg.Content
|
||||||
if toolOutputFullyOmitted(content) {
|
if toolOutputFullyOmitted(content) {
|
||||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: fmt.Sprintf("Error: unknown tool: %s", toolName)})
|
||||||
}
|
}
|
||||||
if emitErr := s.emit(Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: fmt.Sprintf("unknown tool: %s", toolName)}); emitErr != nil {
|
if emitErr := s.emit(newToolFinished(meta, "failed", call.ID, toolName, "", args, content, fmt.Sprintf("unknown tool: %s", toolName))); emitErr != nil {
|
||||||
return toolBatchResult{}, emitErr
|
return toolBatchResult{}, emitErr
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.emit(Event{Type: EventToolStarted, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "running", ToolCallID: call.ID, ToolName: toolName, WorkingDir: plan.workingDir, Args: args}); err != nil {
|
if err := s.emit(newToolStarted(meta, call.ID, toolName, plan.workingDir, args)); err != nil {
|
||||||
return toolBatchResult{}, err
|
return toolBatchResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := s.Tools.Execute(ctx, ToolContext{WorkingDir: plan.workingDir}, call)
|
result, err := s.Tools.Execute(ctx, ToolContext{WorkingDir: plan.workingDir}, call)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rawContent := fmt.Sprintf("Error: %v", err)
|
rawContent := fmt.Sprintf("Error: %v", err)
|
||||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||||
batch.messages = append(batch.messages, msg)
|
batch.messages = append(batch.messages, msg)
|
||||||
projectedMessages = append(projectedMessages, msg)
|
projectedMessages = append(projectedMessages, msg)
|
||||||
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
if toolOutputFullyOmitted(content) {
|
if toolOutputFullyOmitted(content) {
|
||||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||||
}
|
}
|
||||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "failed", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: content, Error: err.Error()}); emitErr != nil {
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "failed", call.ID, toolName, "", args, content, err.Error())); emitErr != nil {
|
||||||
return toolBatchResult{}, emitErr
|
return toolBatchResult{}, emitErr
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -552,15 +607,16 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||||||
}
|
}
|
||||||
rawContent := result.Content
|
rawContent := result.Content
|
||||||
|
|
||||||
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, projectedMessages)
|
msg := s.toolMessageForContext(toolName, call.ID, rawContent, opts, historyTokens+batchTokens)
|
||||||
batch.messages = append(batch.messages, msg)
|
batch.messages = append(batch.messages, msg)
|
||||||
projectedMessages = append(projectedMessages, msg)
|
projectedMessages = append(projectedMessages, msg)
|
||||||
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
|
|
||||||
if toolOutputFullyOmitted(content) {
|
if toolOutputFullyOmitted(content) {
|
||||||
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
batch.overflows = append(batch.overflows, toolOutputOverflow{toolName: toolName, toolCallID: call.ID, content: rawContent})
|
||||||
}
|
}
|
||||||
if err := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "done", ToolCallID: call.ID, ToolName: toolName, WorkingDir: eventWorkingDir, Args: args, Content: content}); err != nil {
|
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "done", call.ID, toolName, eventWorkingDir, args, content, "")); err != nil {
|
||||||
return toolBatchResult{}, err
|
return toolBatchResult{}, err
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
@@ -577,17 +633,21 @@ func (s *Session) executeToolCalls(ctx context.Context, runID string, opts RunOp
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunOptions, messages []api.Message, calls []api.ToolCall) (toolBatchResult, error) {
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
batch := toolBatchResult{
|
batch := toolBatchResult{
|
||||||
messages: make([]api.Message, 0, len(calls)),
|
messages: make([]api.Message, 0, len(calls)),
|
||||||
}
|
}
|
||||||
projectedMessages := append([]api.Message(nil), messages...)
|
projectedMessages := append([]api.Message(nil), messages...)
|
||||||
|
historyTokens := s.estimateRunPromptTokens(opts, messages)
|
||||||
|
batchTokens := 0
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
toolName := call.Function.Name
|
toolName := call.Function.Name
|
||||||
args := call.Function.Arguments.ToMap()
|
args := call.Function.Arguments.ToMap()
|
||||||
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, projectedMessages)
|
msg := s.toolMessageForContext(toolName, call.ID, toolExecutionDisabledMessage, opts, historyTokens+batchTokens)
|
||||||
batch.messages = append(batch.messages, msg)
|
batch.messages = append(batch.messages, msg)
|
||||||
projectedMessages = append(projectedMessages, msg)
|
projectedMessages = append(projectedMessages, msg)
|
||||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "disabled", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: msg.Content, Error: msg.Content}); emitErr != nil {
|
batchTokens += estimateMessagesTokens([]api.Message{msg})
|
||||||
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "disabled", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||||
return toolBatchResult{}, emitErr
|
return toolBatchResult{}, emitErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -595,13 +655,14 @@ func (s *Session) disabledToolCalls(ctx context.Context, runID string, opts RunO
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
|
func (s *Session) skipToolCalls(ctx context.Context, runID string, opts RunOptions, calls []api.ToolCall, content string) ([]api.Message, error) {
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
toolMessages := make([]api.Message, 0, len(calls))
|
toolMessages := make([]api.Message, 0, len(calls))
|
||||||
for _, call := range calls {
|
for _, call := range calls {
|
||||||
toolName := call.Function.Name
|
toolName := call.Function.Name
|
||||||
args := call.Function.Arguments.ToMap()
|
args := call.Function.Arguments.ToMap()
|
||||||
msg := toolMessage(toolName, call.ID, content)
|
msg := toolMessage(toolName, call.ID, content)
|
||||||
toolMessages = append(toolMessages, msg)
|
toolMessages = append(toolMessages, msg)
|
||||||
if emitErr := s.emitIgnoringCanceled(ctx, Event{Type: EventToolFinished, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Status: "skipped", ToolCallID: call.ID, ToolName: toolName, Args: args, Content: msg.Content, Error: msg.Content}); emitErr != nil {
|
if emitErr := s.emitIgnoringCanceled(ctx, newToolFinished(meta, "skipped", call.ID, toolName, "", args, msg.Content, msg.Content)); emitErr != nil {
|
||||||
return nil, emitErr
|
return nil, emitErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -672,7 +733,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if result.Due && !skipNotified {
|
if result.Due && !skipNotified {
|
||||||
if trigger == "" {
|
if trigger == "" {
|
||||||
trigger = "error"
|
trigger = CompactionTriggerError
|
||||||
}
|
}
|
||||||
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
||||||
skipNotified = true
|
skipNotified = true
|
||||||
@@ -682,7 +743,7 @@ func (s *Session) maybeCompact(ctx context.Context, runID string, opts RunOption
|
|||||||
if !result.Compacted {
|
if !result.Compacted {
|
||||||
if result.Due && !skipNotified {
|
if result.Due && !skipNotified {
|
||||||
if trigger == "" {
|
if trigger == "" {
|
||||||
trigger = "due"
|
trigger = CompactionTriggerDue
|
||||||
}
|
}
|
||||||
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
s.emitCompactionSkipped(runID, opts, trigger, result.Reason)
|
||||||
skipNotified = true
|
skipNotified = true
|
||||||
@@ -705,19 +766,19 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||||||
req := s.compactionRequest(runID, opts, messages, latest)
|
req := s.compactionRequest(runID, opts, messages, latest)
|
||||||
req.Force = true
|
req.Force = true
|
||||||
req.KeepUserTurns = &keepUserTurns
|
req.KeepUserTurns = &keepUserTurns
|
||||||
s.emitCompactionStarted(runID, opts, "tool_output")
|
s.emitCompactionStarted(runID, opts, CompactionTriggerToolOutput)
|
||||||
|
|
||||||
result, err := s.Compactor.MaybeCompact(ctx, req)
|
result, err := s.Compactor.MaybeCompact(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if result.Due && !skipNotified {
|
if result.Due && !skipNotified {
|
||||||
s.emitCompactionSkipped(runID, opts, "tool_output", result.Reason)
|
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
|
||||||
skipNotified = true
|
skipNotified = true
|
||||||
}
|
}
|
||||||
return messages, skipNotified, nil
|
return messages, skipNotified, nil
|
||||||
}
|
}
|
||||||
if !result.Compacted {
|
if !result.Compacted {
|
||||||
if result.Due && !skipNotified {
|
if result.Due && !skipNotified {
|
||||||
s.emitCompactionSkipped(runID, opts, "tool_output", result.Reason)
|
s.emitCompactionSkipped(runID, opts, CompactionTriggerToolOutput, result.Reason)
|
||||||
skipNotified = true
|
skipNotified = true
|
||||||
}
|
}
|
||||||
return messages, skipNotified, nil
|
return messages, skipNotified, nil
|
||||||
@@ -733,6 +794,8 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||||||
compacted = append(compacted, assistant)
|
compacted = append(compacted, assistant)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
historyTokens := s.estimateRunPromptTokens(opts, compacted)
|
||||||
|
batchTokens := 0
|
||||||
for _, msg := range toolMessages {
|
for _, msg := range toolMessages {
|
||||||
content := msg.Content
|
content := msg.Content
|
||||||
toolName := msg.ToolName
|
toolName := msg.ToolName
|
||||||
@@ -742,11 +805,12 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||||||
toolName = overflow.toolName
|
toolName = overflow.toolName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refit := s.toolMessageForPostCompactionContext(toolName, msg.ToolCallID, content, opts, compacted)
|
refit := s.toolMessageForPostCompactionContext(toolName, msg.ToolCallID, content, opts, historyTokens+batchTokens)
|
||||||
compacted = append(compacted, refit)
|
compacted = append(compacted, refit)
|
||||||
|
batchTokens += estimateMessagesTokens([]api.Message{refit})
|
||||||
}
|
}
|
||||||
|
|
||||||
s.emitCompacted(runID, opts, compacted, "tool_output", result.Summary)
|
s.emitCompacted(runID, opts, compacted, CompactionTriggerToolOutput, result.Summary)
|
||||||
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
|
if err := s.checkPostCompactionPromptBudget(opts, compacted); err != nil {
|
||||||
return compacted, skipNotified, err
|
return compacted, skipNotified, err
|
||||||
}
|
}
|
||||||
@@ -754,6 +818,7 @@ func (s *Session) compactForToolOutputOverflow(ctx context.Context, runID string
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
|
func (s *Session) compactionRequest(runID string, opts RunOptions, messages []api.Message, latest api.ChatResponse) CompactionRequest {
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
return CompactionRequest{
|
return CompactionRequest{
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Model: opts.Model,
|
Model: opts.Model,
|
||||||
@@ -767,60 +832,30 @@ func (s *Session) compactionRequest(runID string, opts RunOptions, messages []ap
|
|||||||
Think: opts.Think,
|
Think: opts.Think,
|
||||||
ContinueTask: true,
|
ContinueTask: true,
|
||||||
Progress: func(progress CompactionProgress) {
|
Progress: func(progress CompactionProgress) {
|
||||||
_ = s.emit(Event{Type: EventCompactionProgress, RunID: runID, ChatID: opts.ChatID, Model: opts.Model, Tokens: progress.Tokens})
|
_ = s.emit(newCompactionProgress(meta, progress.Tokens))
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, status string) {
|
func (s *Session) emitCompactionStarted(runID string, opts RunOptions, trigger CompactionTrigger) {
|
||||||
_ = s.emit(Event{
|
_ = s.emit(newCompactionStarted(newEventMetadata(runID, opts), trigger))
|
||||||
Type: EventCompactionStarted,
|
|
||||||
RunID: runID,
|
|
||||||
ChatID: opts.ChatID,
|
|
||||||
Model: opts.Model,
|
|
||||||
Status: status,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, status, reason string) {
|
func (s *Session) emitCompactionSkipped(runID string, opts RunOptions, trigger CompactionTrigger, reason string) {
|
||||||
_ = s.emit(Event{
|
_ = s.emit(newCompactionSkipped(newEventMetadata(runID, opts), trigger, CompactionSkippedMessage(reason)))
|
||||||
Type: EventCompactionSkipped,
|
|
||||||
RunID: runID,
|
|
||||||
ChatID: opts.ChatID,
|
|
||||||
Model: opts.Model,
|
|
||||||
Status: status,
|
|
||||||
Content: CompactionSkippedMessage(reason),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, status, summary string) {
|
func (s *Session) emitCompacted(runID string, opts RunOptions, messages []api.Message, trigger CompactionTrigger, summary string) {
|
||||||
_ = s.emit(Event{
|
_ = s.emit(newCompacted(newEventMetadata(runID, opts), messages, trigger, summary))
|
||||||
Type: EventCompacted,
|
|
||||||
RunID: runID,
|
|
||||||
ChatID: opts.ChatID,
|
|
||||||
Model: opts.Model,
|
|
||||||
Status: status,
|
|
||||||
Content: summary,
|
|
||||||
Messages: messages,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) autoCompactionTrigger(req CompactionRequest) string {
|
func (s *Session) autoCompactionTrigger(req CompactionRequest) CompactionTrigger {
|
||||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
if s.Compactor == nil {
|
||||||
if req.Force {
|
return ""
|
||||||
return "force"
|
}
|
||||||
}
|
trigger, should := s.Compactor.ShouldCompact(req)
|
||||||
contextWindow := compactor.contextWindowTokens(req.Options)
|
if should {
|
||||||
threshold := int(float64(contextWindow) * compactor.threshold())
|
return CompactionTrigger(trigger)
|
||||||
if threshold <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if req.Latest.PromptEvalCount > 0 && req.Latest.PromptEvalCount >= threshold {
|
|
||||||
return "prompt_eval"
|
|
||||||
}
|
|
||||||
if estimateCompactionRequestTokens(req) >= threshold {
|
|
||||||
return "estimate"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -833,67 +868,58 @@ func CompactionSkippedMessage(reason string) string {
|
|||||||
return reason
|
return reason
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedMaxToolRounds(value int) int {
|
func resolvedMaxToolRounds(model string, value int) int {
|
||||||
if value == 0 {
|
if value != 0 {
|
||||||
return defaultMaxToolRounds
|
return value
|
||||||
}
|
}
|
||||||
return value
|
if modelref.HasExplicitCloudSource(model) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return defaultMaxToolRounds
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) toolMessageForContext(toolName, toolCallID, content string, opts RunOptions, messages []api.Message) api.Message {
|
// toolMessageWithBudget sizes a tool result message to fit within a token
|
||||||
|
// budget (compaction threshold or context window). baseTokens is the
|
||||||
|
// pre-computed estimate of everything before this message; budgetTokens is
|
||||||
|
// the ceiling. If the message already fits, it is returned with only the
|
||||||
|
// small-context rune cap applied.
|
||||||
|
func (s *Session) toolMessageWithBudget(toolName, toolCallID, content string, opts RunOptions, baseTokens, budgetTokens int) api.Message {
|
||||||
maxRunes := maxToolResultRunes
|
maxRunes := maxToolResultRunes
|
||||||
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
||||||
maxRunes = min(maxRunes, limit)
|
maxRunes = min(maxRunes, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if budgetTokens <= 0 {
|
||||||
|
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||||
|
}
|
||||||
|
|
||||||
msg := toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
msg := toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
||||||
threshold := s.compactionThresholdTokens(opts)
|
projectedTokens := baseTokens + estimateMessagesTokens([]api.Message{msg})
|
||||||
if threshold <= 0 {
|
if projectedTokens < budgetTokens {
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
projected := append(append([]api.Message(nil), messages...), msg)
|
|
||||||
projectedTokens := s.estimateRunPromptTokens(opts, projected)
|
|
||||||
if projectedTokens < threshold {
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
baseTokens := s.estimateRunPromptTokens(opts, messages)
|
|
||||||
overheadTokens := estimateMessagesTokens([]api.Message{{
|
overheadTokens := estimateMessagesTokens([]api.Message{{
|
||||||
Role: "tool",
|
Role: "tool",
|
||||||
ToolName: toolName,
|
ToolName: toolName,
|
||||||
ToolCallID: toolCallID,
|
ToolCallID: toolCallID,
|
||||||
}})
|
}})
|
||||||
// Keep oversized tool output below the compaction threshold before it is
|
// Keep oversized tool output below the budget before it is appended to
|
||||||
// appended to history. This is especially important for <=8k contexts: the
|
// history. This is especially important for <=8k contexts: the next step
|
||||||
// next step must still have enough room to compact and continue the same
|
// must still have enough room to compact and continue the same user
|
||||||
// user request instead of asking the user to prompt again.
|
// request instead of asking the user to prompt again.
|
||||||
availableRunes := (threshold - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
availableRunes := (budgetTokens - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
||||||
maxRunes = min(maxRunes, max(0, availableRunes))
|
maxRunes = min(maxRunes, max(0, availableRunes))
|
||||||
msg.Content = truncateToolResultContentTo(content, maxRunes)
|
msg.Content = truncateToolResultContentTo(content, maxRunes)
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) toolMessageForPostCompactionContext(toolName, toolCallID, content string, opts RunOptions, messages []api.Message) api.Message {
|
func (s *Session) toolMessageForContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
||||||
maxRunes := maxToolResultRunes
|
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.compactionThresholdTokens(opts))
|
||||||
if limit := smallContextToolResultLimitRunes(s.contextWindowTokens(opts)); limit > 0 {
|
}
|
||||||
maxRunes = min(maxRunes, limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
contextWindow := s.contextWindowTokens(opts)
|
func (s *Session) toolMessageForPostCompactionContext(toolName, toolCallID, content string, opts RunOptions, baseTokens int) api.Message {
|
||||||
if contextWindow <= 0 {
|
return s.toolMessageWithBudget(toolName, toolCallID, content, opts, baseTokens, s.contextWindowTokens(opts))
|
||||||
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseTokens := s.estimateRunPromptTokens(opts, messages)
|
|
||||||
overheadTokens := estimateMessagesTokens([]api.Message{{
|
|
||||||
Role: "tool",
|
|
||||||
ToolName: toolName,
|
|
||||||
ToolCallID: toolCallID,
|
|
||||||
}})
|
|
||||||
availableRunes := (contextWindow - baseTokens - overheadTokens - toolTruncationMarkerReserveTokens) * 4
|
|
||||||
maxRunes = min(maxRunes, max(0, availableRunes))
|
|
||||||
return toolMessageWithLimit(toolName, toolCallID, content, maxRunes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolMessageWithLimit(toolName, toolCallID, content string, maxRunes int) api.Message {
|
func toolMessageWithLimit(toolName, toolCallID, content string, maxRunes int) api.Message {
|
||||||
@@ -930,8 +956,8 @@ func (s *Session) compactionThresholdTokens(opts RunOptions) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
configuredThreshold := 0.0
|
configuredThreshold := 0.0
|
||||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
if s.Compactor != nil {
|
||||||
configuredThreshold = compactor.Options.Threshold
|
configuredThreshold = s.Compactor.Threshold()
|
||||||
}
|
}
|
||||||
|
|
||||||
threshold := int(float64(contextWindow) * ResolveCompactionThreshold(configuredThreshold))
|
threshold := int(float64(contextWindow) * ResolveCompactionThreshold(configuredThreshold))
|
||||||
@@ -945,13 +971,7 @@ func (s *Session) contextWindowTokens(opts RunOptions) int {
|
|||||||
if s.Compactor == nil {
|
if s.Compactor == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
return s.Compactor.ContextWindowTokens(opts.Options)
|
||||||
configuredWindow := 0
|
|
||||||
if compactor, ok := s.Compactor.(*SimpleCompactor); ok && compactor != nil {
|
|
||||||
configuredWindow = compactor.Options.ContextWindowTokens
|
|
||||||
}
|
|
||||||
|
|
||||||
return ResolveContextWindowTokens(opts.Options, configuredWindow)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolMessage(toolName, toolCallID, content string) api.Message {
|
func toolMessage(toolName, toolCallID, content string) api.Message {
|
||||||
@@ -971,47 +991,103 @@ func sanitizeMessagesForRequest(messages []api.Message) []api.Message {
|
|||||||
}
|
}
|
||||||
sanitized := make([]api.Message, len(messages))
|
sanitized := make([]api.Message, len(messages))
|
||||||
for i, msg := range messages {
|
for i, msg := range messages {
|
||||||
sanitized[i] = sanitizeMessageForRequest(msg)
|
sanitized[i] = sanitizeMessageForRun(msg)
|
||||||
}
|
}
|
||||||
return sanitized
|
return sanitized
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeMessageForRequest(msg api.Message) api.Message {
|
|
||||||
return sanitizeMessageForRun(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateToolResultContent(content string) string {
|
func truncateToolResultContent(content string) string {
|
||||||
return truncateToolResultContentTo(content, maxToolResultRunes)
|
return truncateToolResultContentTo(content, maxToolResultRunes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateToolResultContentTo(content string, maxRunes int) string {
|
func truncateToolResultContentTo(content string, maxRunes int) string {
|
||||||
if maxRunes <= 0 {
|
return Truncate(content, TruncateConfig{
|
||||||
return fmt.Sprintf("%s omitted ~%d tokens. Use a narrower command, line range, or search query if more detail is needed.]", toolOutputFullOmissionPrefix, approximateTokensFromRunes(len([]rune(content))))
|
MaxRunes: maxRunes,
|
||||||
}
|
HeadTail: true,
|
||||||
if len(content) <= maxRunes {
|
HeadPct: 75,
|
||||||
return content
|
Label: "tool output",
|
||||||
}
|
Hint: "Use a narrower command, line range, or search query if more detail is needed.",
|
||||||
|
FullOmissionPrefix: toolOutputFullOmissionPrefix,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TruncateConfig configures content truncation via Truncate.
|
||||||
|
type TruncateConfig struct {
|
||||||
|
MaxRunes int // rune limit; <= 0 means full omission
|
||||||
|
HeadTail bool // true = head + tail split; false = head only
|
||||||
|
HeadPct int // percentage of MaxRunes for head (e.g. 75); tail gets the rest
|
||||||
|
Label string // e.g. "tool output", "summary", "stdout"
|
||||||
|
Hint string // guidance text appended to marker (optional)
|
||||||
|
FullOmissionPrefix string // marker prefix when MaxRunes <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate truncates content to at most cfg.MaxRunes runes. When HeadTail is
|
||||||
|
// true, it preserves the first HeadPct% and last (100-HeadPct)% of the budget
|
||||||
|
// with a marker between; otherwise it keeps only the head. MaxRunes <= 0
|
||||||
|
// triggers full omission using FullOmissionPrefix. All token counts in
|
||||||
|
// markers use ApproximateTokens.
|
||||||
|
func Truncate(content string, cfg TruncateConfig) string {
|
||||||
runes := []rune(content)
|
runes := []rune(content)
|
||||||
if len(runes) <= maxRunes {
|
total := len(runes)
|
||||||
|
|
||||||
|
if cfg.MaxRunes <= 0 {
|
||||||
|
return fmt.Sprintf("%s omitted ~%d tokens.%s]", cfg.FullOmissionPrefix, ApproximateTokens(total), truncHint(cfg.Hint))
|
||||||
|
}
|
||||||
|
if total <= cfg.MaxRunes {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
head := maxRunes * 3 / 4
|
|
||||||
tail := maxRunes - head
|
if !cfg.HeadTail {
|
||||||
omitted := len(runes) - head - tail
|
head := cfg.MaxRunes
|
||||||
marker := fmt.Sprintf(
|
omitted := total - head
|
||||||
"\n\n[tool output truncated: showing first ~%d tokens and last ~%d tokens; omitted ~%d tokens. Use a narrower command, line range, or search query if more detail is needed.]\n\n",
|
return string(runes[:head]) + TruncMarker(cfg.Label, head, 0, omitted, false, cfg.Hint)
|
||||||
approximateTokensFromRunes(head),
|
}
|
||||||
approximateTokensFromRunes(tail),
|
|
||||||
approximateTokensFromRunes(omitted),
|
head := cfg.MaxRunes * cfg.HeadPct / 100
|
||||||
)
|
tail := cfg.MaxRunes - head
|
||||||
return string(runes[:head]) + marker + string(runes[len(runes)-tail:])
|
omitted := total - head - tail
|
||||||
|
return string(runes[:head]) + TruncMarker(cfg.Label, head, tail, omitted, true, cfg.Hint) + string(runes[len(runes)-tail:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncHint(hint string) string {
|
||||||
|
hint = strings.TrimSpace(hint)
|
||||||
|
if hint == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(hint, ".") {
|
||||||
|
hint += "."
|
||||||
|
}
|
||||||
|
return " " + hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// TruncMarker formats a truncation marker with consistent wording. head and
|
||||||
|
// tail are rune counts; omitted is the count of runes removed. headTail
|
||||||
|
// selects the head+tail vs head-only format. hint is optional guidance text.
|
||||||
|
func TruncMarker(label string, head, tail, omitted int, headTail bool, hint string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("\n\n[")
|
||||||
|
b.WriteString(label)
|
||||||
|
b.WriteString(" truncated: ")
|
||||||
|
if headTail {
|
||||||
|
fmt.Fprintf(&b, "showing first ~%d tokens and last ~%d tokens; ", ApproximateTokens(head), ApproximateTokens(tail))
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "showing first ~%d tokens; ", ApproximateTokens(head))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "omitted ~%d tokens.%s]", ApproximateTokens(omitted), truncHint(hint))
|
||||||
|
if headTail {
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolOutputFullyOmitted(content string) bool {
|
func toolOutputFullyOmitted(content string) bool {
|
||||||
return strings.HasPrefix(content, toolOutputFullOmissionPrefix)
|
return strings.HasPrefix(content, toolOutputFullOmissionPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
func approximateTokensFromRunes(n int) int {
|
// ApproximateTokens estimates token count from a character/byte count using
|
||||||
|
// the standard ~4 chars-per-token heuristic. It is intentionally rough; all
|
||||||
|
// callers use it only for sizing/truncation decisions, not billing.
|
||||||
|
func ApproximateTokens(n int) int {
|
||||||
if n <= 0 {
|
if n <= 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-9
@@ -136,6 +136,14 @@ func (c *recordingCompactor) MaybeCompact(_ context.Context, req CompactionReque
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *recordingCompactor) ContextWindowTokens(options map[string]any) int {
|
||||||
|
return ResolveContextWindowTokens(options, 0)
|
||||||
|
}
|
||||||
|
func (c *recordingCompactor) Threshold() float64 { return 0 }
|
||||||
|
func (c *recordingCompactor) ShouldCompact(_ CompactionRequest) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func (c *oversizedCompactor) MaybeCompact(_ context.Context, req CompactionRequest) (CompactionResult, error) {
|
func (c *oversizedCompactor) MaybeCompact(_ context.Context, req CompactionRequest) (CompactionResult, error) {
|
||||||
c.requests = append(c.requests, req)
|
c.requests = append(c.requests, req)
|
||||||
summary := strings.Repeat("oversized summary ", 300)
|
summary := strings.Repeat("oversized summary ", 300)
|
||||||
@@ -147,6 +155,14 @@ func (c *oversizedCompactor) MaybeCompact(_ context.Context, req CompactionReque
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *oversizedCompactor) ContextWindowTokens(options map[string]any) int {
|
||||||
|
return ResolveContextWindowTokens(options, 0)
|
||||||
|
}
|
||||||
|
func (c *oversizedCompactor) Threshold() float64 { return 0 }
|
||||||
|
func (c *oversizedCompactor) ShouldCompact(_ CompactionRequest) (string, bool) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
type recordingApprovalPrompter struct {
|
type recordingApprovalPrompter struct {
|
||||||
requests []ApprovalRequest
|
requests []ApprovalRequest
|
||||||
results []Approval
|
results []Approval
|
||||||
@@ -309,6 +325,20 @@ func (t namedApprovalTestTool) Execute(context.Context, ToolContext, map[string]
|
|||||||
return ToolResult{Content: "approved"}, nil
|
return ToolResult{Content: "approved"}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApprovalScope mimics the Bash tool's command-scoping behavior so tests can
|
||||||
|
// exercise the shell approval flow without importing the tools package.
|
||||||
|
func (t namedApprovalTestTool) ApprovalScope(args map[string]any) string {
|
||||||
|
if t.name == "bash" || t.name == "powershell" {
|
||||||
|
if cmd, ok := args["command"].(string); ok {
|
||||||
|
cmd = strings.TrimSpace(cmd)
|
||||||
|
if cmd != "" {
|
||||||
|
return t.name + "\x00" + cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
|
||||||
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, req ApprovalRequest) (Approval, error) {
|
func (p *recordingApprovalPrompter) PromptApproval(_ context.Context, req ApprovalRequest) (Approval, error) {
|
||||||
p.requests = append(p.requests, req)
|
p.requests = append(p.requests, req)
|
||||||
if len(p.results) == 0 {
|
if len(p.results) == 0 {
|
||||||
@@ -720,7 +750,7 @@ func TestSessionDisabledToolsOmitToolsAndReturnsDisabledResults(t *testing.T) {
|
|||||||
if event.Type == EventToolCallDetected {
|
if event.Type == EventToolCallDetected {
|
||||||
sawDetected = true
|
sawDetected = true
|
||||||
}
|
}
|
||||||
if event.Type == EventToolFinished && event.Status == "disabled" && event.Content == toolExecutionDisabledMessage {
|
if event.Type == EventToolFinished && event.ToolStatus == ToolStatusDisabled && event.Content == toolExecutionDisabledMessage {
|
||||||
sawDisabled = true
|
sawDisabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -807,7 +837,7 @@ func TestSessionCancellationDuringToolExecutionAppendsToolMessage(t *testing.T)
|
|||||||
if finished == nil {
|
if finished == nil {
|
||||||
t.Fatalf("run finished event missing: %#v", events.events)
|
t.Fatalf("run finished event missing: %#v", events.events)
|
||||||
}
|
}
|
||||||
if finished.Status != "canceled" {
|
if finished.Status != RunStatusCanceled {
|
||||||
t.Fatalf("run status = %q, want canceled", finished.Status)
|
t.Fatalf("run status = %q, want canceled", finished.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -853,7 +883,7 @@ func TestSessionToolLoopAllowsRoundsUnderDefaultCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
|
func TestSessionLocalToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
|
||||||
firstArgs := api.NewToolCallFunctionArguments()
|
firstArgs := api.NewToolCallFunctionArguments()
|
||||||
firstArgs.Set("value", "first")
|
firstArgs.Set("value", "first")
|
||||||
secondArgs := api.NewToolCallFunctionArguments()
|
secondArgs := api.NewToolCallFunctionArguments()
|
||||||
@@ -902,7 +932,7 @@ func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
|
|||||||
|
|
||||||
result, err := session.Run(context.Background(), RunOptions{
|
result, err := session.Run(context.Background(), RunOptions{
|
||||||
ChatID: "chat-1",
|
ChatID: "chat-1",
|
||||||
Model: "model",
|
Model: "test:local",
|
||||||
NewMessages: []api.Message{{Role: "user", Content: "hit cap"}},
|
NewMessages: []api.Message{{Role: "user", Content: "hit cap"}},
|
||||||
MaxToolRounds: 1,
|
MaxToolRounds: 1,
|
||||||
})
|
})
|
||||||
@@ -926,7 +956,7 @@ func TestSessionToolRoundLimitAppendsSkippedToolMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
|
func TestSessionLocalToolLoopStopsAtDefaultRoundCap(t *testing.T) {
|
||||||
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+1)
|
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+1)
|
||||||
for range defaultMaxToolRounds + 1 {
|
for range defaultMaxToolRounds + 1 {
|
||||||
args := api.NewToolCallFunctionArguments()
|
args := api.NewToolCallFunctionArguments()
|
||||||
@@ -952,7 +982,7 @@ func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, err := session.Run(context.Background(), RunOptions{
|
_, err := session.Run(context.Background(), RunOptions{
|
||||||
Model: "model",
|
Model: "test:local",
|
||||||
NewMessages: []api.Message{{Role: "user", Content: "keep going"}},
|
NewMessages: []api.Message{{Role: "user", Content: "keep going"}},
|
||||||
})
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), "tool round limit reached after 100 rounds") {
|
if err == nil || !strings.Contains(err.Error(), "tool round limit reached after 100 rounds") {
|
||||||
@@ -963,6 +993,53 @@ func TestSessionToolLoopStopsAtDefaultRoundCap(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionCloudToolLoopHonorsExplicitRoundCap(t *testing.T) {
|
||||||
|
args := api.NewToolCallFunctionArguments()
|
||||||
|
args.Set("value", "hello")
|
||||||
|
client := &fakeClient{responses: [][]api.ChatResponse{
|
||||||
|
{{
|
||||||
|
Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||||
|
ID: "call-1",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "echo_tool",
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
}},
|
||||||
|
{{
|
||||||
|
Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||||
|
ID: "call-2",
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "echo_tool",
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
registry := &Registry{}
|
||||||
|
registry.Register(staticTool{})
|
||||||
|
session := &Session{
|
||||||
|
Client: client,
|
||||||
|
Tools: registry,
|
||||||
|
ApprovalState: approvalStateForTest(true, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := session.Run(context.Background(), RunOptions{
|
||||||
|
Model: "test:cloud",
|
||||||
|
NewMessages: []api.Message{{Role: "user", Content: "keep going"}},
|
||||||
|
MaxToolRounds: 1,
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "tool round limit reached after 1 rounds") {
|
||||||
|
t.Fatalf("error = %v, want explicit tool-round limit", err)
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
t.Fatal("expected partial result with skipped tool message")
|
||||||
|
}
|
||||||
|
if client.calls != 2 {
|
||||||
|
t.Fatalf("client calls = %d, want 2", client.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSessionToolLoopNegativeLimitIsUnlimited(t *testing.T) {
|
func TestSessionToolLoopNegativeLimitIsUnlimited(t *testing.T) {
|
||||||
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+2)
|
responses := make([][]api.ChatResponse, 0, defaultMaxToolRounds+2)
|
||||||
for range defaultMaxToolRounds + 1 {
|
for range defaultMaxToolRounds + 1 {
|
||||||
@@ -1993,7 +2070,7 @@ func TestSessionAllowAllApprovalSkipsFuturePrompts(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !session.ApprovalState.AllowAll() {
|
if !session.ApprovalState.AllGranted() {
|
||||||
t.Fatal("session did not remember allow all")
|
t.Fatal("session did not remember allow all")
|
||||||
}
|
}
|
||||||
if len(prompter.requests) != 1 {
|
if len(prompter.requests) != 1 {
|
||||||
@@ -2050,7 +2127,7 @@ func TestSessionAllowToolApprovalSkipsFuturePromptForSameTool(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if session.ApprovalState.AllowAll() {
|
if session.ApprovalState.AllGranted() {
|
||||||
t.Fatal("allowing one tool enabled full access")
|
t.Fatal("allowing one tool enabled full access")
|
||||||
}
|
}
|
||||||
if !session.ApprovalState.Allows("approval_tool") {
|
if !session.ApprovalState.Allows("approval_tool") {
|
||||||
@@ -2110,7 +2187,7 @@ func TestSessionAllowShellApprovalScopesToExactCommand(t *testing.T) {
|
|||||||
registry.Register(namedApprovalTestTool{name: "bash"})
|
registry.Register(namedApprovalTestTool{name: "bash"})
|
||||||
prompter := &recordingApprovalPrompter{
|
prompter := &recordingApprovalPrompter{
|
||||||
results: []Approval{
|
results: []Approval{
|
||||||
{AllowScopes: []string{toolApprovalScope("bash", map[string]any{"command": "pwd"})}},
|
{AllowScopes: []string{toolApprovalScope(namedApprovalTestTool{name: "bash"}, "bash", map[string]any{"command": "pwd"})}},
|
||||||
{Allow: true},
|
{Allow: true},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// activateSkill loads opts.SkillName from the catalog and injects a synthetic
|
||||||
|
// assistant tool call plus tool result before the first model request, so the
|
||||||
|
// transcript looks like a real skill tool invocation. It emits the same
|
||||||
|
// tool_call_detected -> tool_started -> tool_finished lifecycle the model path
|
||||||
|
// uses, and returns the messages to prepend. A blank SkillName is a no-op.
|
||||||
|
func (s *Session) activateSkill(ctx context.Context, runID string, opts RunOptions) ([]api.Message, error) {
|
||||||
|
name := strings.TrimSpace(opts.SkillName)
|
||||||
|
if name == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
skill, err := s.Skills.Load(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := api.NewToolCallFunctionArguments()
|
||||||
|
args.Set("name", skill.Name)
|
||||||
|
call := api.ToolCall{
|
||||||
|
ID: "call_skill_" + uuid.NewString(),
|
||||||
|
Function: api.ToolCallFunction{Name: "skill", Arguments: args},
|
||||||
|
}
|
||||||
|
result := api.Message{
|
||||||
|
Role: "tool",
|
||||||
|
ToolName: "skill",
|
||||||
|
ToolCallID: call.ID,
|
||||||
|
Content: skill.Content(),
|
||||||
|
}
|
||||||
|
meta := newEventMetadata(runID, opts)
|
||||||
|
if err := s.emit(newToolCallDetected(meta, []api.ToolCall{call})); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.emit(newToolStarted(meta, call.ID, "skill", s.currentWorkingDir(), args.ToMap())); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.emitIgnoringCanceled(ctx, newToolFinished(meta, ToolStatusDone, call.ID, "skill", s.currentWorkingDir(), args.ToMap(), result.Content, "")); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return []api.Message{
|
||||||
|
{Role: "assistant", ToolCalls: []api.ToolCall{call}},
|
||||||
|
result,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type skillTestClient struct{ requests []*api.ChatRequest }
|
||||||
|
|
||||||
|
func (c *skillTestClient) Chat(_ context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||||
|
c.requests = append(c.requests, req)
|
||||||
|
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "Done."}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSkillCatalog(t *testing.T) *SkillCatalog {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "release-notes")
|
||||||
|
if err := os.Mkdir(path, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionSkillActivationPreservesCallAndResultOrder(t *testing.T) {
|
||||||
|
catalog := testSkillCatalog(t)
|
||||||
|
client := &skillTestClient{}
|
||||||
|
events := &recordingEventSink{}
|
||||||
|
result, err := (&Session{Client: client, Skills: catalog, EventSinks: []EventSink{events}}).Run(context.Background(), RunOptions{
|
||||||
|
Model: "test",
|
||||||
|
NewMessages: []api.Message{{Role: "user", Content: "draft release notes"}},
|
||||||
|
SkillName: "release-notes",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Messages) != 4 {
|
||||||
|
t.Fatalf("transcript = %#v", result.Messages)
|
||||||
|
}
|
||||||
|
call, toolTranscript := result.Messages[1], result.Messages[2]
|
||||||
|
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "skill" || !strings.HasPrefix(call.ToolCalls[0].ID, "call_skill_") {
|
||||||
|
t.Fatalf("call message = %#v", call)
|
||||||
|
}
|
||||||
|
if toolTranscript.Role != "tool" || toolTranscript.ToolName != "skill" || toolTranscript.ToolCallID != call.ToolCalls[0].ID || !strings.Contains(toolTranscript.Content, "Use concise bullets.") {
|
||||||
|
t.Fatalf("tool result = %#v", toolTranscript)
|
||||||
|
}
|
||||||
|
if len(client.requests) != 1 || len(client.requests[0].Messages) != 3 || client.requests[0].Messages[2].ToolCallID != call.ToolCalls[0].ID {
|
||||||
|
t.Fatalf("model request did not preserve transcript: %#v", client.requests)
|
||||||
|
}
|
||||||
|
var skillEvents []EventType
|
||||||
|
for _, event := range events.events {
|
||||||
|
if event.ToolName == "skill" || event.Type == EventToolCallDetected {
|
||||||
|
skillEvents = append(skillEvents, event.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(skillEvents) < 3 {
|
||||||
|
t.Fatalf("skill event order = %#v, want tool_call_detected,tool_started,tool_finished", skillEvents)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join([]string{string(skillEvents[0]), string(skillEvents[1]), string(skillEvents[2])}, ","), "tool_call_detected,tool_started,tool_finished"; got != want {
|
||||||
|
t.Fatalf("skill event order = %#v, want %s", skillEvents, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SkillsDirEnv overrides the user-level Ollama-owned skills directory. The
|
||||||
|
// cross-client .agents/skills/ convention and project-level .ollama/skills/
|
||||||
|
// are also scanned (see LoadDefaultSkills); on a name collision, Ollama-owned
|
||||||
|
// directories take precedence over .agents/skills/, and project-level takes
|
||||||
|
// precedence over user-level.
|
||||||
|
SkillsDirEnv = "OLLAMA_SKILLS"
|
||||||
|
skillFilename = "SKILL.md"
|
||||||
|
maxSkillBytes = 1 << 20
|
||||||
|
|
||||||
|
bundledSkillCreatorName = "skill-creator"
|
||||||
|
bundledSkillCreatorContent = `---
|
||||||
|
name: skill-creator
|
||||||
|
description: Create or improve reusable skills. Use when the user wants a reusable skill, asks how to author SKILL.md, or needs help installing a skill.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create a skill
|
||||||
|
|
||||||
|
Create a focused, reusable instruction package. Treat a skill as guidance for the model, not as a way to gain new permissions or bypass safety controls.
|
||||||
|
|
||||||
|
## Choose the location
|
||||||
|
|
||||||
|
Create user skills beside this one. The skill directory shown in the loaded skill context is this skill's location; its parent is the user skill root. This bundled skill normally lives at ~/.ollama/skills/skill-creator, so new user skills normally go at ~/.ollama/skills/<skill-name>/SKILL.md.
|
||||||
|
|
||||||
|
Use a project-local skill directory only when the user asks to keep the skill with that project. Do not overwrite an existing skill without the user's approval. New and changed skills are discovered when the agent starts, so tell the user to begin a new agent session afterward.
|
||||||
|
|
||||||
|
## Follow the required shape
|
||||||
|
|
||||||
|
Use the directory name as the skill name. Use lowercase letters, numbers, and single hyphens only. Keep the name short and no longer than 64 characters.
|
||||||
|
|
||||||
|
Every skill needs a SKILL.md with YAML frontmatter followed by Markdown instructions:
|
||||||
|
|
||||||
|
~~~md
|
||||||
|
---
|
||||||
|
name: release-notes
|
||||||
|
description: Draft concise release notes from completed changes. Use when the user asks for a changelog, release notes, or GitHub release copy.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Draft release notes
|
||||||
|
|
||||||
|
Write the workflow here.
|
||||||
|
~~~
|
||||||
|
|
||||||
|
Require a non-empty description that says both what the skill does and when to use it. Keep the body procedural and concise. Put detailed schemas, long examples, and variant-specific guidance in references/ only when the skill needs them.
|
||||||
|
|
||||||
|
Use scripts/ for repeatable or fragile operations that benefit from deterministic execution. Use assets/ for files that belong in generated output. Do not add README files, changelogs, or setup notes that do not help the model perform the task.
|
||||||
|
|
||||||
|
## Create safely
|
||||||
|
|
||||||
|
1. Identify the repeated task, expected inputs, and useful output.
|
||||||
|
2. Choose the smallest name and description that reliably trigger the skill.
|
||||||
|
3. Create the folder and SKILL.md; add resources only when they remove real repeated work.
|
||||||
|
4. Re-read the completed file and verify its frontmatter, directory-name match, and relative resource paths.
|
||||||
|
5. Tell the user where it was created and that a new agent session will discover it.
|
||||||
|
|
||||||
|
Skills provide instructions only. They do not grant filesystem, network, shell, or approval privileges, and they do not make a tool available. Use only the tools that are actually available, follow their normal approval rules, and ask before actions that need user authorization.
|
||||||
|
`
|
||||||
|
)
|
||||||
|
|
||||||
|
var skillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||||
|
|
||||||
|
// SkillsDir returns the canonical runtime-owned skill directory.
|
||||||
|
func SkillsDir() (string, error) {
|
||||||
|
if path := strings.TrimSpace(os.Getenv(SkillsDirEnv)); path != "" {
|
||||||
|
return filepath.Abs(path)
|
||||||
|
}
|
||||||
|
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
|
||||||
|
return filepath.Join(xdg, "ollama", "skills"), nil
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(home, ".ollama", "skills"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill is a validated, loadable instruction set. It never grants tool
|
||||||
|
// permissions; it is supplied to the model as ordinary tool-result content.
|
||||||
|
type Skill struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Instructions string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Skill) Content() string {
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "<skill name=%q>\n%s\n", s.Name, strings.TrimSpace(s.Instructions))
|
||||||
|
if s.Path != "" {
|
||||||
|
dir := filepath.Dir(s.Path)
|
||||||
|
fmt.Fprintf(&b, "Skill directory: %s\n", dir)
|
||||||
|
b.WriteString("Relative paths in this skill are relative to the skill directory.\n")
|
||||||
|
}
|
||||||
|
if resources := s.resources(); len(resources) > 0 {
|
||||||
|
b.WriteString("<skill_resources>\n")
|
||||||
|
for _, r := range resources {
|
||||||
|
fmt.Fprintf(&b, " <file>%s</file>\n", r)
|
||||||
|
}
|
||||||
|
b.WriteString("</skill_resources>\n")
|
||||||
|
}
|
||||||
|
b.WriteString("</skill>")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resources lists bundled files one level deep under scripts/, references/,
|
||||||
|
// and assets/ without reading them, so the model can load them on demand.
|
||||||
|
func (s Skill) resources() []string {
|
||||||
|
if s.Path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(s.Path)
|
||||||
|
var resources []string
|
||||||
|
for _, sub := range []string{"scripts", "references", "assets"} {
|
||||||
|
entries, err := os.ReadDir(filepath.Join(dir, sub))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resources = append(resources, sub+"/"+e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(resources)
|
||||||
|
return resources
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillCatalog contains valid skills and diagnostics for ignored invalid
|
||||||
|
// entries, so one malformed skill cannot hide the rest.
|
||||||
|
type SkillCatalog struct {
|
||||||
|
dir string
|
||||||
|
skills map[string]Skill
|
||||||
|
diagnostics []error
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiscoverSkills(dir string) (*SkillCatalog, error) {
|
||||||
|
dir, err := filepath.Abs(strings.TrimSpace(dir))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
catalog := &SkillCatalog{dir: dir, skills: make(map[string]Skill)}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read skills directory: %w", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
// Follow symlinks so users can point at shared skill repositories.
|
||||||
|
// The link name (not the target) is the canonical skill name.
|
||||||
|
info, err := os.Stat(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("skill %q: %w", name, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !skillName.MatchString(name) {
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("invalid skill directory %q", name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
skill, err := parseSkill(filepath.Join(dir, name, skillFilename), name)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catalog.skills[skill.Name] = skill
|
||||||
|
}
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadDefaultSkills discovers skills from the spec's scopes, merged with
|
||||||
|
// deterministic precedence. Roots are scanned lowest-precedence first so later
|
||||||
|
// roots override earlier ones on name collisions (recording a diagnostic):
|
||||||
|
//
|
||||||
|
// 1. ~/.agents/skills/ (user, cross-client)
|
||||||
|
// 2. user Ollama skills dir (user, Ollama-owned; SkillsDir)
|
||||||
|
// 3. <project>/.agents/skills/ (project, cross-client)
|
||||||
|
// 4. <project>/.ollama/skills/ (project, Ollama-owned)
|
||||||
|
//
|
||||||
|
// Project-level overrides user-level, and within a scope Ollama-owned
|
||||||
|
// directories override .agents/skills/. projectDir is the agent's working
|
||||||
|
// directory at startup (discovery is a session-start snapshot per the spec).
|
||||||
|
func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
|
||||||
|
roots, err := defaultSkillRoots(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
catalog := &SkillCatalog{skills: make(map[string]Skill)}
|
||||||
|
bundled, err := bundledSkillCreator()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
catalog.skills[bundled.Name] = bundled
|
||||||
|
if err := installBundledSkillCreator(); err != nil {
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, err)
|
||||||
|
}
|
||||||
|
for _, root := range roots {
|
||||||
|
sub, err := DiscoverSkills(root.path)
|
||||||
|
if err != nil {
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, fmt.Errorf("discover skills in %s: %w", root.path, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
catalog.diagnostics = append(catalog.diagnostics, sub.diagnostics...)
|
||||||
|
for _, skill := range sub.skills {
|
||||||
|
// Name collisions across roots are expected precedence resolution,
|
||||||
|
// not errors: later (higher-precedence) roots legitimately override
|
||||||
|
// earlier ones. The skill is still loaded; no diagnostic needed.
|
||||||
|
catalog.skills[skill.Name] = skill
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bundledSkillCreator() (Skill, error) {
|
||||||
|
skill, err := parseSkillContent("", bundledSkillCreatorName, bundledSkillCreatorContent)
|
||||||
|
if err != nil {
|
||||||
|
return Skill{}, fmt.Errorf("load bundled %s skill: %w", bundledSkillCreatorName, err)
|
||||||
|
}
|
||||||
|
return skill, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func installBundledSkillCreator() error {
|
||||||
|
dir, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve bundled skill directory: %w", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create bundled skill directory: %w", err)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err == nil && string(contents) == bundledSkillCreatorContent {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("read bundled skill: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(bundledSkillCreatorContent), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write bundled skill: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillRoot struct {
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSkillRoots returns skill directories ordered lowest- to
|
||||||
|
// highest-precedence. Non-existent directories are scanned harmlessly
|
||||||
|
// (DiscoverSkills skips them).
|
||||||
|
func defaultSkillRoots(projectDir string) ([]skillRoot, error) {
|
||||||
|
var roots []skillRoot
|
||||||
|
|
||||||
|
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||||
|
roots = append(roots, skillRoot{path: filepath.Join(home, ".agents", "skills")})
|
||||||
|
}
|
||||||
|
|
||||||
|
userOllama, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
roots = append(roots, skillRoot{path: userOllama})
|
||||||
|
|
||||||
|
projectDir = strings.TrimSpace(projectDir)
|
||||||
|
if projectDir != "" {
|
||||||
|
if abs, err := filepath.Abs(projectDir); err == nil {
|
||||||
|
roots = append(roots,
|
||||||
|
skillRoot{path: filepath.Join(abs, ".agents", "skills")},
|
||||||
|
skillRoot{path: filepath.Join(abs, ".ollama", "skills")},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SkillCatalog) Dir() string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SkillCatalog) List() []Skill {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list := make([]Skill, 0, len(c.skills))
|
||||||
|
for _, skill := range c.skills {
|
||||||
|
list = append(list, skill)
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SkillCatalog) Diagnostics() []error {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]error(nil), c.diagnostics...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SkillCatalog) Load(name string) (Skill, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if !skillName.MatchString(name) {
|
||||||
|
return Skill{}, fmt.Errorf("invalid skill name %q", name)
|
||||||
|
}
|
||||||
|
if c == nil {
|
||||||
|
return Skill{}, errors.New("skills are unavailable")
|
||||||
|
}
|
||||||
|
skill, ok := c.skills[name]
|
||||||
|
if !ok {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q not found in %s", name, c.dir)
|
||||||
|
}
|
||||||
|
return skill, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SystemContext advertises the catalog without expanding full instructions in
|
||||||
|
// every request. The skill call is the explicit loading boundary.
|
||||||
|
func (c *SkillCatalog) SystemContext() string {
|
||||||
|
list := c.List()
|
||||||
|
if len(list) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
lines := []string{"<available_skills>"}
|
||||||
|
for _, skill := range list {
|
||||||
|
description := skill.Description
|
||||||
|
if description == "" {
|
||||||
|
description = "No description provided."
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- %s: %s", skill.Name, description))
|
||||||
|
}
|
||||||
|
lines = append(lines, "</available_skills>", "Load a matching skill with the skill tool before following its instructions. Skills only provide instructions; use ordinary tools for filesystem or network access, with their normal approval rules.")
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSkill(path, directoryName string) (Skill, error) {
|
||||||
|
// Stat (not Lstat) so a symlinked SKILL.md resolves to its target file.
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return Skill{}, err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: %s is not a regular file", directoryName, skillFilename)
|
||||||
|
}
|
||||||
|
if info.Size() > maxSkillBytes {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: %s exceeds %d bytes", directoryName, skillFilename, maxSkillBytes)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Skill{}, fmt.Errorf("read skill %q: %w", directoryName, err)
|
||||||
|
}
|
||||||
|
return parseSkillContent(path, directoryName, string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSkillContent(path, directoryName, input string) (Skill, error) {
|
||||||
|
instructions := strings.TrimSpace(input)
|
||||||
|
if instructions == "" {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: %s is empty", directoryName, skillFilename)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(instructions, "---\n") && !strings.HasPrefix(instructions, "---\r\n") {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: missing YAML front matter", directoryName)
|
||||||
|
}
|
||||||
|
metadata, body, err := skillFrontMatter(instructions)
|
||||||
|
if err != nil {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: %w", directoryName, err)
|
||||||
|
}
|
||||||
|
if metadata.Name == "" {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: front matter requires name", directoryName)
|
||||||
|
}
|
||||||
|
if metadata.Description == "" {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: front matter requires description", directoryName)
|
||||||
|
}
|
||||||
|
if !skillName.MatchString(metadata.Name) {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: invalid front matter name %q", directoryName, metadata.Name)
|
||||||
|
}
|
||||||
|
if metadata.Name != directoryName {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: front matter name %q must match directory name", directoryName, metadata.Name)
|
||||||
|
}
|
||||||
|
skill := Skill{Name: metadata.Name, Description: metadata.Description, Path: path}
|
||||||
|
instructions = body
|
||||||
|
if strings.TrimSpace(instructions) == "" {
|
||||||
|
return Skill{}, fmt.Errorf("skill %q: instructions are empty", directoryName)
|
||||||
|
}
|
||||||
|
skill.Instructions = strings.TrimSpace(instructions)
|
||||||
|
return skill, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type skillFrontMatterMetadata struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Metadata map[string]any `yaml:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillFrontMatter(input string) (skillFrontMatterMetadata, string, error) {
|
||||||
|
input = strings.ReplaceAll(input, "\r\n", "\n")
|
||||||
|
lines := strings.Split(input, "\n")
|
||||||
|
if len(lines) < 3 || lines[0] != "---" {
|
||||||
|
return skillFrontMatterMetadata{}, "", errors.New("invalid front matter")
|
||||||
|
}
|
||||||
|
for i := 1; i < len(lines); i++ {
|
||||||
|
if lines[i] == "---" {
|
||||||
|
var metadata skillFrontMatterMetadata
|
||||||
|
if err := yaml.Unmarshal([]byte(strings.Join(lines[1:i], "\n")), &metadata); err != nil {
|
||||||
|
return skillFrontMatterMetadata{}, "", fmt.Errorf("parse YAML front matter: %w", err)
|
||||||
|
}
|
||||||
|
metadata.Name = strings.TrimSpace(metadata.Name)
|
||||||
|
metadata.Description = strings.TrimSpace(metadata.Description)
|
||||||
|
return metadata, strings.Join(lines[i+1:], "\n"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skillFrontMatterMetadata{}, "", errors.New("front matter is not closed")
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeCatalogSkill(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(content, "---") {
|
||||||
|
content = "---\nname: " + name + "\ndescription: Test skill.\n---\n" + content
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(path, skillFilename), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverAndLoadSkills(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeCatalogSkill(t, dir, "release-notes", "---\nname: release-notes\ndescription: Draft concise release notes.\nmetadata:\n author: Ollama\n labels:\n - release\n - docs\n---\n# Release notes\n\nUse short bullets.")
|
||||||
|
catalog, err := DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list := catalog.List()
|
||||||
|
if len(list) != 1 || list[0].Name != "release-notes" || list[0].Description != "Draft concise release notes." {
|
||||||
|
t.Fatalf("skills = %#v", list)
|
||||||
|
}
|
||||||
|
skill, err := catalog.Load("release-notes")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(skill.Content(), `<skill name="release-notes">`) || !strings.Contains(skill.Content(), "Use short bullets.") {
|
||||||
|
t.Fatalf("skill content = %q", skill.Content())
|
||||||
|
}
|
||||||
|
if context := catalog.SystemContext(); !strings.Contains(context, "release-notes: Draft concise release notes.") || !strings.Contains(context, "normal approval rules") {
|
||||||
|
t.Fatalf("system context = %q", context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSkillsSkipsMalformedEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeCatalogSkill(t, dir, "valid", "do the useful thing")
|
||||||
|
writeCatalogSkill(t, dir, "mismatched", "---\nname: whatever\ndescription: wrong name\n---\nbody")
|
||||||
|
// Genuinely malformed front matter (a line without a key:value pair) is still rejected.
|
||||||
|
writeCatalogSkill(t, dir, "broken", "---\nname: broken\ndescription\n---\nnope")
|
||||||
|
writeCatalogSkill(t, dir, "missing-name", "---\ndescription: missing name\n---\nbody")
|
||||||
|
writeCatalogSkill(t, dir, "missing-description", "---\nname: missing-description\n---\nbody")
|
||||||
|
writeCatalogSkill(t, dir, "bad-name", "---\nname: bad_name\ndescription: invalid name\n---\nbody")
|
||||||
|
writeCatalogSkill(t, dir, "under_score", "---\nname: under_score\ndescription: invalid directory\n---\nbody")
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "no-front-matter"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "no-front-matter", skillFilename), []byte("body"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := len(catalog.List()), 1; got != want {
|
||||||
|
t.Fatalf("valid skills = %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
if got, want := len(catalog.Diagnostics()), 7; got != want {
|
||||||
|
t.Fatalf("diagnostics = %d, want %d: %#v", got, want, catalog.Diagnostics())
|
||||||
|
}
|
||||||
|
if _, err := catalog.Load("broken"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||||
|
t.Fatalf("load broken error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := catalog.Load("../valid"); err == nil || !strings.Contains(err.Error(), "invalid skill name") {
|
||||||
|
t.Fatalf("unsafe name error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoverSkillsFollowsSymlinks(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := t.TempDir()
|
||||||
|
writeCatalogSkill(t, target, "shared", "---\nname: shared\ndescription: From a linked repo.\n---\nshared instructions")
|
||||||
|
if err := os.Symlink(filepath.Join(target, "shared"), filepath.Join(dir, "shared")); err != nil {
|
||||||
|
t.Skipf("symlink not supported: %v", err)
|
||||||
|
}
|
||||||
|
catalog, err := DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
list := catalog.List()
|
||||||
|
if len(list) != 1 || list[0].Name != "shared" || list[0].Description != "From a linked repo." {
|
||||||
|
t.Fatalf("symlinked skills = %#v", list)
|
||||||
|
}
|
||||||
|
if !strings.Contains(list[0].Content(), "shared instructions") {
|
||||||
|
t.Fatalf("symlinked skill content = %q", list[0].Content())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
project := t.TempDir()
|
||||||
|
writeCatalogSkill(t, filepath.Join(project, ".ollama", "skills"), "release-notes", "project instructions")
|
||||||
|
|
||||||
|
badRoot := filepath.Join(t.TempDir(), "not-a-directory")
|
||||||
|
if err := os.WriteFile(badRoot, []byte("not a directory"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(SkillsDirEnv, badRoot)
|
||||||
|
|
||||||
|
catalog, err := LoadDefaultSkills(project)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := catalog.Load("release-notes"); err != nil {
|
||||||
|
t.Fatalf("valid skill was hidden by bad root: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := catalog.Load(bundledSkillCreatorName); err != nil {
|
||||||
|
t.Fatalf("bundled skill was hidden by bad root: %v", err)
|
||||||
|
}
|
||||||
|
var foundDiagnostic bool
|
||||||
|
for _, diagnostic := range catalog.Diagnostics() {
|
||||||
|
if strings.Contains(diagnostic.Error(), badRoot) {
|
||||||
|
foundDiagnostic = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundDiagnostic {
|
||||||
|
t.Fatalf("diagnostics = %#v, want bad root %q", catalog.Diagnostics(), badRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultSkillsInstallsBundledSkillCreator(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv(SkillsDirEnv, dir)
|
||||||
|
|
||||||
|
catalog, err := LoadDefaultSkills("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skill, err := catalog.Load(bundledSkillCreatorName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
|
||||||
|
contents, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(contents) != bundledSkillCreatorContent {
|
||||||
|
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||||
|
}
|
||||||
|
if skill.Path != path {
|
||||||
|
t.Fatalf("skill path = %q, want %q", skill.Path, path)
|
||||||
|
}
|
||||||
|
if !strings.Contains(skill.Content(), "Skill directory: "+filepath.Dir(path)) {
|
||||||
|
t.Fatalf("skill content does not identify its directory: %q", skill.Content())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultSkillsUpdatesExistingSkillCreator(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv(SkillsDirEnv, dir)
|
||||||
|
writeCatalogSkill(t, dir, bundledSkillCreatorName, "custom instructions")
|
||||||
|
|
||||||
|
if _, err := LoadDefaultSkills(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
contents, err := os.ReadFile(filepath.Join(dir, bundledSkillCreatorName, skillFilename))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(contents) != bundledSkillCreatorContent {
|
||||||
|
t.Fatalf("installed skill = %q, want bundled contents", contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
|
||||||
|
base := t.TempDir()
|
||||||
|
|
||||||
|
override := filepath.Join(base, "skills-override")
|
||||||
|
t.Setenv(SkillsDirEnv, override)
|
||||||
|
got, err := SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want, err := filepath.Abs(override)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("SkillsDir override = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv(SkillsDirEnv, "")
|
||||||
|
xdg := filepath.Join(base, "xdg")
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", xdg)
|
||||||
|
if got, err := SkillsDir(); err != nil || got != filepath.Join(xdg, "ollama", "skills") {
|
||||||
|
t.Fatalf("SkillsDir xdg = %q, want %q, %v", got, filepath.Join(xdg, "ollama", "skills"), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", "")
|
||||||
|
home := filepath.Join(base, "home")
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
if got, err := SkillsDir(); err != nil || got != filepath.Join(home, ".ollama", "skills") {
|
||||||
|
t.Fatalf("SkillsDir default = %q, want %q, %v", got, filepath.Join(home, ".ollama", "skills"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home) // Windows: os.UserHomeDir uses %USERPROFILE%
|
||||||
|
|
||||||
|
userOllama := t.TempDir()
|
||||||
|
t.Setenv(SkillsDirEnv, userOllama)
|
||||||
|
|
||||||
|
userAgents := filepath.Join(home, ".agents", "skills")
|
||||||
|
project := t.TempDir()
|
||||||
|
projectAgents := filepath.Join(project, ".agents", "skills")
|
||||||
|
projectOllama := filepath.Join(project, ".ollama", "skills")
|
||||||
|
|
||||||
|
// release-notes exists in all four roots; project ollama must win.
|
||||||
|
writeCatalogSkill(t, userAgents, "release-notes", "from user agents")
|
||||||
|
writeCatalogSkill(t, userOllama, "release-notes", "from user ollama")
|
||||||
|
writeCatalogSkill(t, projectOllama, "release-notes", "from project ollama")
|
||||||
|
// code-review exists in both project roots; project ollama beats project agents.
|
||||||
|
writeCatalogSkill(t, projectAgents, "code-review", "from project agents")
|
||||||
|
writeCatalogSkill(t, projectOllama, "code-review", "from project ollama")
|
||||||
|
// unique appears only in user ollama (via env override).
|
||||||
|
writeCatalogSkill(t, userOllama, "unique", "only here")
|
||||||
|
|
||||||
|
catalog, err := LoadDefaultSkills(project)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rn, err := catalog.Load("release-notes")
|
||||||
|
if err != nil || !strings.Contains(rn.Instructions, "from project ollama") || !strings.Contains(rn.Path, ".ollama") {
|
||||||
|
t.Fatalf("release-notes = %#v, want project ollama to win", rn)
|
||||||
|
}
|
||||||
|
cr, err := catalog.Load("code-review")
|
||||||
|
if err != nil || !strings.Contains(cr.Instructions, "from project ollama") {
|
||||||
|
t.Fatalf("code-review = %#v, want project ollama to win over project agents", cr)
|
||||||
|
}
|
||||||
|
if _, err := catalog.Load("unique"); err != nil {
|
||||||
|
t.Fatalf("unique should load from user ollama: %v", err)
|
||||||
|
}
|
||||||
|
// Collisions are resolved silently by precedence — no diagnostics.
|
||||||
|
for _, d := range catalog.Diagnostics() {
|
||||||
|
if strings.Contains(d.Error(), "shadows") {
|
||||||
|
t.Fatalf("unexpected shadow diagnostic: %v", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillContentListsDirectoryAndResources(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
skillDir := filepath.Join(root, "pdf-processing")
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "scripts"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(skillDir, "references"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: pdf-processing\ndescription: Handle PDFs.\n---\nHandle PDFs."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "scripts", "extract.py"), []byte("#!/usr/bin/env python3"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "references", "ref.md"), []byte("ref"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := DiscoverSkills(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
skill, err := catalog.Load("pdf-processing")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := skill.Content()
|
||||||
|
if !strings.Contains(content, "Skill directory:") || !strings.Contains(content, skillDir) {
|
||||||
|
t.Fatalf("content missing skill directory: %q", content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(content, "<file>scripts/extract.py</file>") || !strings.Contains(content, "<file>references/ref.md</file>") {
|
||||||
|
t.Fatalf("content missing resource listing: %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-8
@@ -53,7 +53,24 @@ func (b *Bash) RequiresApproval(map[string]any) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ApprovalScope scopes shell approval to the exact, trimmed command string
|
||||||
|
// using a NUL separator: "<tool>\x00<command>". "Always allow this command"
|
||||||
|
// matches ONLY that precise string — any whitespace, quoting, or casing
|
||||||
|
// variant re-prompts. The NUL separator is safe because a shell command
|
||||||
|
// string cannot contain a literal NUL.
|
||||||
|
func (b *Bash) ApprovalScope(args map[string]any) string {
|
||||||
|
name := b.Name()
|
||||||
|
if command, ok := args["command"].(string); ok {
|
||||||
|
command = strings.TrimSpace(command)
|
||||||
|
if command != "" {
|
||||||
|
return name + "\x00" + command
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Bash) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
func (b *Bash) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
// TODO: use shared agent.RequiredStringArg for the "command" parameter (see agent package cleanup plan).
|
||||||
command, ok := args["command"].(string)
|
command, ok := args["command"].(string)
|
||||||
if !ok || strings.TrimSpace(command) == "" {
|
if !ok || strings.TrimSpace(command) == "" {
|
||||||
return agent.ToolResult{}, fmt.Errorf("command parameter is required")
|
return agent.ToolResult{}, fmt.Errorf("command parameter is required")
|
||||||
@@ -415,7 +432,7 @@ func (b *boundedOutput) String(label string) string {
|
|||||||
if omitted == 0 {
|
if omitted == 0 {
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
return content + fmt.Sprintf("\n\n[%s truncated: omitted ~%d tokens]", label, approximateTokensFromBytes(omitted))
|
return content + agent.TruncMarker(label, safeLen, 0, omitted, false, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func utf8SafePrefixLen(p []byte) int {
|
func utf8SafePrefixLen(p []byte) int {
|
||||||
@@ -431,10 +448,3 @@ func utf8SafePrefixLen(p []byte) int {
|
|||||||
}
|
}
|
||||||
return len(p)
|
return len(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
func approximateTokensFromBytes(n int) int {
|
|
||||||
if n <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return max(1, (n+3)/4)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestBashBoundsOutputWhileRunning(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.Content, "[stdout truncated: omitted ~") || !strings.Contains(result.Content, " tokens]") {
|
if !strings.Contains(result.Content, "[stdout truncated: showing first ~") || !strings.Contains(result.Content, "omitted ~") || !strings.Contains(result.Content, " tokens.]") {
|
||||||
t.Fatalf("content = %q, want stdout truncation marker", result.Content)
|
t.Fatalf("content = %q, want stdout truncation marker", result.Content)
|
||||||
}
|
}
|
||||||
if count, want := strings.Count(result.Content, "x"), shellTestCapturedXCount(); count != want {
|
if count, want := strings.Count(result.Content, "x"), shellTestCapturedXCount(); count != want {
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ func (r *Read) RequiresApproval(map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
func (r *Read) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
// TODO: use shared agent.RequiredStringArg / agent.OptionalIntArg for args (see agent package cleanup plan).
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok || strings.TrimSpace(path) == "" {
|
if !ok || strings.TrimSpace(path) == "" {
|
||||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||||
@@ -141,6 +142,7 @@ func (e *Edit) RequiresApproval(map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
func (e *Edit) Execute(ctx context.Context, toolCtx agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
// TODO: use shared agent.RequiredStringArg / agent.OptionalBoolArg for args (see agent package cleanup plan).
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok || strings.TrimSpace(path) == "" {
|
if !ok || strings.TrimSpace(path) == "" {
|
||||||
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
return agent.ToolResult{}, fmt.Errorf("path parameter is required")
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/agent"
|
||||||
|
"github.com/ollama/ollama/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skill is the model-facing adapter for the core agent skill catalog.
|
||||||
|
// It only supplies instructions; regular tools retain their own approval
|
||||||
|
// requirements for filesystem or network access.
|
||||||
|
type Skill struct{ Catalog *agent.SkillCatalog }
|
||||||
|
|
||||||
|
func (t *Skill) Name() string { return "skill" }
|
||||||
|
|
||||||
|
func (t *Skill) Description() string {
|
||||||
|
return "Load a named Ollama skill and return its instructions."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Skill) Schema() api.ToolFunction {
|
||||||
|
props := api.NewToolPropertiesMap()
|
||||||
|
props.Set("name", api.ToolProperty{Type: api.PropertyType{"string"}, Description: "Name of the skill to load."})
|
||||||
|
return api.ToolFunction{Name: t.Name(), Description: t.Description(), Parameters: api.ToolFunctionParameters{Type: "object", Properties: props, Required: []string{"name"}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Skill) Execute(_ context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
name, ok := args["name"].(string)
|
||||||
|
if !ok {
|
||||||
|
return agent.ToolResult{}, errors.New("name parameter is required")
|
||||||
|
}
|
||||||
|
skill, err := t.Catalog.Load(name)
|
||||||
|
if err != nil {
|
||||||
|
return agent.ToolResult{}, err
|
||||||
|
}
|
||||||
|
return agent.ToolResult{Content: skill.Content()}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ollama/ollama/agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSkillLoadsCoreCatalogWithoutApproval(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "release-notes")
|
||||||
|
if err := os.Mkdir(path, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := agent.DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tool := &Skill{Catalog: catalog}
|
||||||
|
if agent.ToolRequiresApproval(tool, map[string]any{"name": "release-notes"}) {
|
||||||
|
t.Fatal("loading a skill must not change ordinary tool approval semantics")
|
||||||
|
}
|
||||||
|
result, err := tool.Execute(context.Background(), agent.ToolContext{}, map[string]any{"name": "release-notes"})
|
||||||
|
if err != nil || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||||
|
t.Fatalf("tool result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-17
@@ -53,6 +53,7 @@ func (w *WebSearch) RequiresApproval(map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebSearch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
func (w *WebSearch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
// TODO: use shared agent.RequiredStringArg for the "query" parameter (see agent package cleanup plan).
|
||||||
if internalcloud.Disabled() {
|
if internalcloud.Disabled() {
|
||||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web search is unavailable"))
|
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web search is unavailable"))
|
||||||
}
|
}
|
||||||
@@ -130,6 +131,7 @@ func (w *WebFetch) RequiresApproval(map[string]any) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[string]any) (agent.ToolResult, error) {
|
||||||
|
// TODO: use shared agent.RequiredStringArg for the "url" parameter (see agent package cleanup plan).
|
||||||
if internalcloud.Disabled() {
|
if internalcloud.Disabled() {
|
||||||
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web fetch is unavailable"))
|
return agent.ToolResult{}, errors.New(internalcloud.DisabledError("web fetch is unavailable"))
|
||||||
}
|
}
|
||||||
@@ -176,21 +178,9 @@ func (w *WebFetch) Execute(ctx context.Context, _ agent.ToolContext, args map[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
func truncateWebFetchContent(content string) string {
|
func truncateWebFetchContent(content string) string {
|
||||||
runes := []rune(content)
|
return agent.Truncate(content, agent.TruncateConfig{
|
||||||
if len(runes) <= maxWebFetchContentRunes {
|
MaxRunes: maxWebFetchContentRunes,
|
||||||
return content
|
Label: "tool output",
|
||||||
}
|
Hint: "Use a narrower request or search query if more detail is needed.",
|
||||||
omitted := len(runes) - maxWebFetchContentRunes
|
})
|
||||||
return string(runes[:maxWebFetchContentRunes]) + fmt.Sprintf(
|
|
||||||
"\n\n[tool output truncated: showing first ~%d tokens; omitted ~%d tokens. Use a narrower request or search query if more detail is needed.]",
|
|
||||||
approximateToolTokensFromRunes(maxWebFetchContentRunes),
|
|
||||||
approximateToolTokensFromRunes(omitted),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func approximateToolTokensFromRunes(n int) int {
|
|
||||||
if n <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return max(1, (n+3)/4)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -777,6 +777,18 @@ func (c *StreamConverter) Process(r api.ChatResponse) []StreamEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.Message.Thinking != "" && !c.thinkingDone {
|
if r.Message.Thinking != "" && !c.thinkingDone {
|
||||||
|
if c.textStarted {
|
||||||
|
events = append(events, StreamEvent{
|
||||||
|
Event: "content_block_stop",
|
||||||
|
Data: ContentBlockStopEvent{
|
||||||
|
Type: "content_block_stop",
|
||||||
|
Index: c.contentIndex,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
c.contentIndex++
|
||||||
|
c.textStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
if !c.thinkingStarted {
|
if !c.thinkingStarted {
|
||||||
c.thinkingStarted = true
|
c.thinkingStarted = true
|
||||||
events = append(events, StreamEvent{
|
events = append(events, StreamEvent{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package anthropic
|
|||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -1140,6 +1141,56 @@ func TestStreamConverter_ThinkingDirectlyFollowedByToolCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStreamConverter_TextBeforeThinking(t *testing.T) {
|
||||||
|
conv := NewStreamConverter("msg_123", "test-model", 0)
|
||||||
|
|
||||||
|
responses := []api.ChatResponse{
|
||||||
|
{Message: api.Message{Role: "assistant", Content: "---\n"}},
|
||||||
|
{Message: api.Message{Role: "assistant", Thinking: "Let me think."}},
|
||||||
|
{
|
||||||
|
Message: api.Message{Role: "assistant", Content: "The answer."},
|
||||||
|
Done: true,
|
||||||
|
DoneReason: "stop",
|
||||||
|
Metrics: api.Metrics{PromptEvalCount: 10, EvalCount: 5},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var got []string
|
||||||
|
for _, response := range responses {
|
||||||
|
for _, event := range conv.Process(response) {
|
||||||
|
switch data := event.Data.(type) {
|
||||||
|
case ContentBlockStartEvent:
|
||||||
|
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.ContentBlock.Type, data.Index))
|
||||||
|
case ContentBlockDeltaEvent:
|
||||||
|
got = append(got, fmt.Sprintf("%s:%s:%d", event.Event, data.Delta.Type, data.Index))
|
||||||
|
case ContentBlockStopEvent:
|
||||||
|
got = append(got, fmt.Sprintf("%s:%d", event.Event, data.Index))
|
||||||
|
default:
|
||||||
|
got = append(got, event.Event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{
|
||||||
|
"message_start",
|
||||||
|
"content_block_start:text:0",
|
||||||
|
"content_block_delta:text_delta:0",
|
||||||
|
"content_block_stop:0",
|
||||||
|
"content_block_start:thinking:1",
|
||||||
|
"content_block_delta:thinking_delta:1",
|
||||||
|
"content_block_stop:1",
|
||||||
|
"content_block_start:text:2",
|
||||||
|
"content_block_delta:text_delta:2",
|
||||||
|
"content_block_stop:2",
|
||||||
|
"message_delta",
|
||||||
|
"message_stop",
|
||||||
|
}
|
||||||
|
|
||||||
|
if diff := cmp.Diff(want, got); diff != "" {
|
||||||
|
t.Fatalf("unexpected stream events (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStreamConverter_ToolCallWithUnmarshalableArgs(t *testing.T) {
|
func TestStreamConverter_ToolCallWithUnmarshalableArgs(t *testing.T) {
|
||||||
// Test that unmarshalable arguments (like channels) are handled gracefully
|
// Test that unmarshalable arguments (like channels) are handled gracefully
|
||||||
// and don't cause a panic or corrupt stream
|
// and don't cause a panic or corrupt stream
|
||||||
|
|||||||
+31
-22
@@ -220,16 +220,23 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
|||||||
return agentContextWindowForModel(ctx, client, model, fallback)
|
return agentContextWindowForModel(ctx, client, model, fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load agent skills: %w", err)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range skillCatalog.Diagnostics() {
|
||||||
|
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
|
||||||
|
}
|
||||||
var registry *coreagent.Registry
|
var registry *coreagent.Registry
|
||||||
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
|
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
|
||||||
return agentToolsRegistry(ctx, client, model)
|
return agentToolsRegistry(ctx, client, model, skillCatalog)
|
||||||
}
|
}
|
||||||
if opts.Model != "" {
|
if opts.Model != "" {
|
||||||
registry = agentToolsRegistry(cmd.Context(), client, opts.Model)
|
registry = agentToolsRegistry(cmd.Context(), client, opts.Model, skillCatalog)
|
||||||
}
|
}
|
||||||
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, "", cwd)
|
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
|
||||||
|
|
||||||
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
|
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
|
||||||
Model: opts.Model,
|
Model: opts.Model,
|
||||||
Client: client,
|
Client: client,
|
||||||
Tools: registry,
|
Tools: registry,
|
||||||
@@ -244,9 +251,10 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
|||||||
OnModelSelected: func(_ context.Context, model string) error {
|
OnModelSelected: func(_ context.Context, model string) error {
|
||||||
return config.SetLastModel(model)
|
return config.SetLastModel(model)
|
||||||
},
|
},
|
||||||
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
|
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry, toolsDisabled bool) string {
|
||||||
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), "", cwd)
|
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
|
||||||
},
|
},
|
||||||
|
Skills: skillCatalog,
|
||||||
SystemPrompt: systemPrompt,
|
SystemPrompt: systemPrompt,
|
||||||
WorkingDir: cwd,
|
WorkingDir: cwd,
|
||||||
Format: opts.Format,
|
Format: opts.Format,
|
||||||
@@ -284,6 +292,16 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func agentSkillSystemContext(catalog *coreagent.SkillCatalog, registry *coreagent.Registry, toolsDisabled bool) string {
|
||||||
|
if toolsDisabled || registry == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, ok := registry.Get("skill"); !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return catalog.SystemContext()
|
||||||
|
}
|
||||||
|
|
||||||
func selectAgentModel(ctx context.Context, client *api.Client, current string) (string, error) {
|
func selectAgentModel(ctx context.Context, client *api.Client, current string) (string, error) {
|
||||||
models, err := agentModelOptions(ctx, client)
|
models, err := agentModelOptions(ctx, client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -331,18 +349,10 @@ func agentWorkingDir() string {
|
|||||||
return cwd
|
return cwd
|
||||||
}
|
}
|
||||||
|
|
||||||
func agentSystemPrompt(modelName string, modelSystem string, extra string) string {
|
|
||||||
return agentSystemPromptWithWorkingDir(modelName, modelSystem, extra, agentWorkingDir())
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentSystemPromptWithWorkingDir(modelName string, modelSystem string, extra string, workingDir string) string {
|
func agentSystemPromptWithWorkingDir(modelName string, modelSystem string, extra string, workingDir string) string {
|
||||||
return agentSystemPromptAtWithWorkingDir(time.Now(), modelName, modelSystem, extra, workingDir)
|
return agentSystemPromptAtWithWorkingDir(time.Now(), modelName, modelSystem, extra, workingDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
func agentSystemPromptAt(now time.Time, modelName string, modelSystem string, extra string) string {
|
|
||||||
return agentSystemPromptAtWithWorkingDir(now, modelName, modelSystem, extra, agentWorkingDir())
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentSystemPromptAtWithWorkingDir(now time.Time, modelName string, modelSystem string, extra string, workingDir string) string {
|
func agentSystemPromptAtWithWorkingDir(now time.Time, modelName string, modelSystem string, extra string, workingDir string) string {
|
||||||
var parts []string
|
var parts []string
|
||||||
parts = append(parts, agentDefaultSystemPromptWithWorkingDir(now, modelName, workingDir))
|
parts = append(parts, agentDefaultSystemPromptWithWorkingDir(now, modelName, workingDir))
|
||||||
@@ -355,10 +365,6 @@ func agentSystemPromptAtWithWorkingDir(now time.Time, modelName string, modelSys
|
|||||||
return strings.Join(parts, "\n\n")
|
return strings.Join(parts, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func agentDefaultSystemPrompt(now time.Time, modelName string) string {
|
|
||||||
return agentDefaultSystemPromptWithWorkingDir(now, modelName, agentWorkingDir())
|
|
||||||
}
|
|
||||||
|
|
||||||
func agentDefaultSystemPromptWithWorkingDir(now time.Time, modelName string, workingDir string) string {
|
func agentDefaultSystemPromptWithWorkingDir(now time.Time, modelName string, workingDir string) string {
|
||||||
date := now.Format("Monday, January 2, 2006")
|
date := now.Format("Monday, January 2, 2006")
|
||||||
shellName := "bash"
|
shellName := "bash"
|
||||||
@@ -371,9 +377,6 @@ func agentDefaultSystemPromptWithWorkingDir(now time.Time, modelName string, wor
|
|||||||
"Current date: " + date + ".",
|
"Current date: " + date + ".",
|
||||||
"",
|
"",
|
||||||
}
|
}
|
||||||
if workingDir != "" {
|
|
||||||
parts = append(parts, "Current working directory: "+strconv.Quote(workingDir)+".", "")
|
|
||||||
}
|
|
||||||
parts = append(parts,
|
parts = append(parts,
|
||||||
"Be concise, practical, and action-oriented. Use tools when they materially help. Verify current or fast-changing facts with web tools when available; otherwise state uncertainty.",
|
"Be concise, practical, and action-oriented. Use tools when they materially help. Verify current or fast-changing facts with web tools when available; otherwise state uncertainty.",
|
||||||
"",
|
"",
|
||||||
@@ -381,6 +384,9 @@ func agentDefaultSystemPromptWithWorkingDir(now time.Time, modelName string, wor
|
|||||||
"",
|
"",
|
||||||
"Tell the user about meaningful changes, verification, failures, blockers, assumptions, and risks. Summarize routine tool output instead of dumping it.",
|
"Tell the user about meaningful changes, verification, failures, blockers, assumptions, and risks. Summarize routine tool output instead of dumping it.",
|
||||||
)
|
)
|
||||||
|
if workingDir != "" {
|
||||||
|
parts = append(parts, "Current working directory: "+strconv.Quote(workingDir)+".")
|
||||||
|
}
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +402,7 @@ func agentSystemFromShow(ctx context.Context, client *api.Client, modelName stri
|
|||||||
return resp.System
|
return resp.System
|
||||||
}
|
}
|
||||||
|
|
||||||
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string) *coreagent.Registry {
|
func agentToolsRegistry(ctx context.Context, client *api.Client, modelName string, skillCatalog *coreagent.SkillCatalog) *coreagent.Registry {
|
||||||
supportsTools, err := agentModelSupportsTools(ctx, client, modelName)
|
supportsTools, err := agentModelSupportsTools(ctx, client, modelName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
|
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m could not check model capabilities: %v\n", err)
|
||||||
@@ -411,6 +417,9 @@ func agentToolsRegistry(ctx context.Context, client *api.Client, modelName strin
|
|||||||
}
|
}
|
||||||
registry.Register(&agenttools.Read{})
|
registry.Register(&agenttools.Read{})
|
||||||
registry.Register(&agenttools.Edit{})
|
registry.Register(&agenttools.Edit{})
|
||||||
|
if len(skillCatalog.List()) > 0 {
|
||||||
|
registry.Register(&agenttools.Skill{Catalog: skillCatalog})
|
||||||
|
}
|
||||||
|
|
||||||
if os.Getenv("OLLAMA_AGENT_DISABLE_WEBSEARCH") == "" {
|
if os.Getenv("OLLAMA_AGENT_DISABLE_WEBSEARCH") == "" {
|
||||||
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
|
if disabled, known := agentCloudStatusDisabled(ctx, client); !known || !disabled {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
coreagent "github.com/ollama/ollama/agent"
|
||||||
|
agenttools "github.com/ollama/ollama/agent/tools"
|
||||||
"github.com/ollama/ollama/api"
|
"github.com/ollama/ollama/api"
|
||||||
"github.com/ollama/ollama/cmd/config"
|
"github.com/ollama/ollama/cmd/config"
|
||||||
agentchat "github.com/ollama/ollama/cmd/tui/chat"
|
agentchat "github.com/ollama/ollama/cmd/tui/chat"
|
||||||
@@ -49,6 +53,50 @@ func TestAgentWorkingDirIgnoresGetwdFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentSystemPromptIncludesSkillCatalog(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "release-notes"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "release-notes", "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft releases.\n---\nUse bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := coreagent.DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := agentSystemPromptAtWithWorkingDir(time.Date(2026, 7, 14, 0, 0, 0, 0, time.UTC), "model", "", catalog.SystemContext(), "")
|
||||||
|
if !strings.Contains(got, "release-notes: Draft releases.") || !strings.Contains(got, "normal approval rules") {
|
||||||
|
t.Fatalf("system prompt missing skill context: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentSkillSystemContextRequiresAvailableEnabledSkillTool(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "release-notes"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "release-notes", "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft releases.\n---\nUse bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := coreagent.DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
registry := &coreagent.Registry{}
|
||||||
|
registry.Register(&agenttools.Skill{Catalog: catalog})
|
||||||
|
|
||||||
|
if got := agentSkillSystemContext(catalog, registry, false); !strings.Contains(got, "release-notes: Draft releases.") {
|
||||||
|
t.Fatalf("enabled skill context = %q", got)
|
||||||
|
}
|
||||||
|
if got := agentSkillSystemContext(catalog, registry, true); got != "" {
|
||||||
|
t.Fatalf("disabled tools should omit skill context, got %q", got)
|
||||||
|
}
|
||||||
|
if got := agentSkillSystemContext(catalog, &coreagent.Registry{}, false); got != "" {
|
||||||
|
t.Fatalf("unavailable skill tool should omit skill context, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAgentSelectionItemsUseLaunchSections(t *testing.T) {
|
func TestAgentSelectionItemsUseLaunchSections(t *testing.T) {
|
||||||
items := agentSelectionItems([]agentchat.ModelOption{
|
items := agentSelectionItems([]agentchat.ModelOption{
|
||||||
{Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true},
|
{Name: "glm-5.2:cloud", Description: "cloud", Recommended: true, Cloud: true},
|
||||||
|
|||||||
@@ -57,11 +57,15 @@ func (m chatModel) allowAllToolsEnabled() bool {
|
|||||||
if m.approvalState == nil {
|
if m.approvalState == nil {
|
||||||
return m.defaultAllowAll
|
return m.defaultAllowAll
|
||||||
}
|
}
|
||||||
return m.approvalState.AllowAll()
|
return m.approvalState.AllGranted()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *chatModel) setAllowAllTools(allowAll bool) {
|
func (m *chatModel) setAllowAllTools(allowAll bool) {
|
||||||
m.ensureApprovalState().SetAllowAll(allowAll)
|
if allowAll {
|
||||||
|
m.ensureApprovalState().GrantAll()
|
||||||
|
} else {
|
||||||
|
m.ensureApprovalState().Set(false, nil)
|
||||||
|
}
|
||||||
m.opts.AllowAllTools = allowAll
|
m.opts.AllowAllTools = allowAll
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +180,7 @@ func (m chatModel) resolveApprovalPrompt(choice chatApprovalChoice) (tea.Model,
|
|||||||
}
|
}
|
||||||
allowScopes := approvalScopes(prompt.request)
|
allowScopes := approvalScopes(prompt.request)
|
||||||
if choice.allowTools {
|
if choice.allowTools {
|
||||||
m.ensureApprovalState().AllowScopes(allowScopes)
|
m.ensureApprovalState().GrantScopes(allowScopes)
|
||||||
}
|
}
|
||||||
for _, call := range prompt.request.Calls {
|
for _, call := range prompt.request.Calls {
|
||||||
if idx := m.findToolEntry(call.ToolCallID); idx >= 0 && m.entries[idx].status == "approval" {
|
if idx := m.findToolEntry(call.ToolCallID); idx >= 0 && m.entries[idx].status == "approval" {
|
||||||
@@ -386,7 +390,7 @@ func (c *chatApprovalController) preapproved(request coreagent.ApprovalRequest)
|
|||||||
if c == nil {
|
if c == nil {
|
||||||
return coreagent.Approval{}, false
|
return coreagent.Approval{}, false
|
||||||
}
|
}
|
||||||
if c.state.AllowAll() {
|
if c.state.AllGranted() {
|
||||||
return coreagent.Approval{Allow: true, AllowAll: true}, true
|
return coreagent.Approval{Allow: true, AllowAll: true}, true
|
||||||
}
|
}
|
||||||
scopes := approvalScopes(request)
|
scopes := approvalScopes(request)
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ func TestChatApprovalControllerAutoApprovesAfterFullAccessToggle(t *testing.T) {
|
|||||||
events := make(chan tea.Msg, 1)
|
events := make(chan tea.Msg, 1)
|
||||||
state := testApprovalState(false, nil)
|
state := testApprovalState(false, nil)
|
||||||
controller := newChatApprovalController(events, state)
|
controller := newChatApprovalController(events, state)
|
||||||
state.SetAllowAll(true)
|
state.GrantAll()
|
||||||
|
|
||||||
result, err := controller.PromptApproval(context.Background(), testApprovalRequest())
|
result, err := controller.PromptApproval(context.Background(), testApprovalRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+24
-3
@@ -54,12 +54,13 @@ type Options struct {
|
|||||||
Messages []api.Message
|
Messages []api.Message
|
||||||
Client coreagent.ChatClient
|
Client coreagent.ChatClient
|
||||||
Tools *coreagent.Registry
|
Tools *coreagent.Registry
|
||||||
|
Skills *coreagent.SkillCatalog
|
||||||
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
|
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
|
||||||
ToolsDisabled bool
|
ToolsDisabled bool
|
||||||
MultiModalForModel func(context.Context, string) bool
|
MultiModalForModel func(context.Context, string) bool
|
||||||
ModelOptions func(context.Context) ([]ModelOption, error)
|
ModelOptions func(context.Context) ([]ModelOption, error)
|
||||||
OnModelSelected func(context.Context, string) error
|
OnModelSelected func(context.Context, string) error
|
||||||
SystemPromptForModel func(context.Context, string, *coreagent.Registry) string
|
SystemPromptForModel func(context.Context, string, *coreagent.Registry, bool) string
|
||||||
ApprovalPrompter coreagent.ApprovalPrompter
|
ApprovalPrompter coreagent.ApprovalPrompter
|
||||||
EventSinks []coreagent.EventSink
|
EventSinks []coreagent.EventSink
|
||||||
AllowAllTools bool
|
AllowAllTools bool
|
||||||
@@ -1017,7 +1018,7 @@ func (m *chatModel) startRun(input string) (tea.Model, tea.Cmd) {
|
|||||||
m.status = "error"
|
m.status = "error"
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "")
|
return m.startRunWithMessages(displayInput, message.Content, []api.Message{message}, "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
|
func (m *chatModel) userMessageFromInput(displayInput, userInput string) (string, api.Message, error) {
|
||||||
@@ -1076,7 +1077,25 @@ func pluralSuffix(count int) string {
|
|||||||
return "s"
|
return "s"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt string) (tea.Model, tea.Cmd) {
|
func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
prompt = strings.TrimSpace(prompt)
|
||||||
|
// The skill instructions are delivered via the synthetic tool result that
|
||||||
|
// follows this user turn, so this message orients the model rather than
|
||||||
|
// re-requesting the skill. When a prompt is supplied it becomes the task.
|
||||||
|
content := prompt
|
||||||
|
if content == "" {
|
||||||
|
content = "The " + name + " skill is loaded; follow its instructions for this request."
|
||||||
|
}
|
||||||
|
message := api.Message{Role: "user", Content: content}
|
||||||
|
displayInput := "/" + name
|
||||||
|
if prompt != "" {
|
||||||
|
displayInput = "/" + name + " " + prompt
|
||||||
|
}
|
||||||
|
return m.startRunWithMessages(displayInput, "", []api.Message{message}, "", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
|
||||||
m.refreshContextWindowTokens(m.opts.Model)
|
m.refreshContextWindowTokens(m.opts.Model)
|
||||||
m.addPromptHistory(historyInput)
|
m.addPromptHistory(historyInput)
|
||||||
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
|
||||||
@@ -1111,6 +1130,7 @@ func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newM
|
|||||||
Client: m.opts.Client,
|
Client: m.opts.Client,
|
||||||
EventSinks: eventSinks,
|
EventSinks: eventSinks,
|
||||||
Tools: m.opts.Tools,
|
Tools: m.opts.Tools,
|
||||||
|
Skills: m.opts.Skills,
|
||||||
DisableTools: m.opts.ToolsDisabled,
|
DisableTools: m.opts.ToolsDisabled,
|
||||||
ApprovalPrompter: m.approvalPrompterForRun(m.approvalController),
|
ApprovalPrompter: m.approvalPrompterForRun(m.approvalController),
|
||||||
ApprovalState: m.ensureApprovalState(),
|
ApprovalState: m.ensureApprovalState(),
|
||||||
@@ -1127,6 +1147,7 @@ func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newM
|
|||||||
Options: m.opts.Options,
|
Options: m.opts.Options,
|
||||||
Think: m.opts.Think,
|
Think: m.opts.Think,
|
||||||
KeepAlive: m.opts.KeepAlive,
|
KeepAlive: m.opts.KeepAlive,
|
||||||
|
SkillName: skillName,
|
||||||
}
|
}
|
||||||
|
|
||||||
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
|
persistedMessages := make([]api.Message, 0, len(m.messages)+len(newMessages))
|
||||||
|
|||||||
@@ -233,16 +233,15 @@ func (m *chatModel) addDetectedToolCalls(calls []api.ToolCall) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func toolFinishedStatus(event coreagent.Event) string {
|
func toolFinishedStatus(event coreagent.Event) string {
|
||||||
switch strings.TrimSpace(event.Status) {
|
switch event.ToolStatus {
|
||||||
case "denied":
|
case coreagent.ToolStatusDenied:
|
||||||
return "denied"
|
return "denied"
|
||||||
case "disabled":
|
case coreagent.ToolStatusDisabled:
|
||||||
return "disabled"
|
return "disabled"
|
||||||
case "done":
|
case coreagent.ToolStatusDone:
|
||||||
return "done"
|
return "done"
|
||||||
case "error":
|
|
||||||
return "error"
|
|
||||||
}
|
}
|
||||||
|
// failed/skipped/unknown: derive from content and error fields.
|
||||||
if isDeniedToolResult(event.Content) || isDeniedToolResult(event.Error) {
|
if isDeniedToolResult(event.Content) || isDeniedToolResult(event.Error) {
|
||||||
return "denied"
|
return "denied"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func TestApplyAgentEventRendersDeniedCommandAsDenied(t *testing.T) {
|
|||||||
|
|
||||||
m.applyAgentEvent(coreagent.Event{
|
m.applyAgentEvent(coreagent.Event{
|
||||||
Type: coreagent.EventToolFinished,
|
Type: coreagent.EventToolFinished,
|
||||||
Status: "denied",
|
ToolStatus: coreagent.ToolStatusDenied,
|
||||||
ToolCallID: "call-1",
|
ToolCallID: "call-1",
|
||||||
ToolName: "bash",
|
ToolName: "bash",
|
||||||
Args: args,
|
Args: args,
|
||||||
@@ -341,8 +341,8 @@ func TestApplyAgentEventGroupsPreviouslyDeniedCommandsAtNextToolBoundary(t *test
|
|||||||
secondArgs := map[string]any{"command": "ls"}
|
secondArgs := map[string]any{"command": "ls"}
|
||||||
thirdArgs := map[string]any{"command": "date"}
|
thirdArgs := map[string]any{"command": "date"}
|
||||||
|
|
||||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, Status: "denied", ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolStatus: coreagent.ToolStatusDenied, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
|
||||||
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, Status: "denied", ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
|
m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolStatus: coreagent.ToolStatusDenied, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "Tool execution denied.", Error: "Tool execution denied."})
|
||||||
|
|
||||||
if len(m.entries) != 2 {
|
if len(m.entries) != 2 {
|
||||||
t.Fatalf("entries = %d, want two stable denied command rows: %#v", len(m.entries), m.entries)
|
t.Fatalf("entries = %d, want two stable denied command rows: %#v", len(m.entries), m.entries)
|
||||||
|
|||||||
+107
-5
@@ -55,6 +55,7 @@ var chatSlashCommands = []chatSlashCommand{
|
|||||||
{name: "/new", description: "start a new chat"},
|
{name: "/new", description: "start a new chat"},
|
||||||
{name: "/think", description: "set thinking mode"},
|
{name: "/think", description: "set thinking mode"},
|
||||||
{name: "/tools", description: "toggle tools on or off"},
|
{name: "/tools", description: "toggle tools on or off"},
|
||||||
|
{name: "/skills", description: "list available skills"},
|
||||||
{name: "/compact", description: "summarize older context"},
|
{name: "/compact", description: "summarize older context"},
|
||||||
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
{name: "/help", description: "show commands", aliases: []string{"/?"}},
|
||||||
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
|
||||||
@@ -115,6 +116,7 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
|||||||
if command != "" {
|
if command != "" {
|
||||||
input = strings.TrimSpace(command + " " + args)
|
input = strings.TrimSpace(command + " " + args)
|
||||||
}
|
}
|
||||||
|
skillName, skillPrompt, skillOK := m.skillSlashInvocation(input)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case command == "/bye":
|
case command == "/bye":
|
||||||
@@ -131,6 +133,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
|||||||
return m.handleThinkCommand(args)
|
return m.handleThinkCommand(args)
|
||||||
case command == "/tools":
|
case command == "/tools":
|
||||||
return m.handleToolsCommand(args)
|
return m.handleToolsCommand(args)
|
||||||
|
case command == "/skills":
|
||||||
|
return m.handleSkillsCommand(args)
|
||||||
case command == "/prompt":
|
case command == "/prompt":
|
||||||
return m.handlePromptCommand(args)
|
return m.handlePromptCommand(args)
|
||||||
case command == "/save":
|
case command == "/save":
|
||||||
@@ -139,6 +143,8 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
|||||||
return m.resetChat("new chat")
|
return m.resetChat("new chat")
|
||||||
case command == "/compact" && args == "":
|
case command == "/compact" && args == "":
|
||||||
return m.startManualCompaction()
|
return m.startManualCompaction()
|
||||||
|
case skillOK:
|
||||||
|
return m.startSkillRun(skillName, skillPrompt)
|
||||||
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
|
case strings.HasPrefix(input, "/") && m.slashInputIsMultimodalFile(input):
|
||||||
return m.startRun(input)
|
return m.startRun(input)
|
||||||
case strings.HasPrefix(input, "/"):
|
case strings.HasPrefix(input, "/"):
|
||||||
@@ -149,6 +155,63 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
|
|||||||
return m.startRun(input)
|
return m.startRun(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
|
||||||
|
if strings.TrimSpace(args) != "" {
|
||||||
|
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills"}))
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
skills := m.opts.Skills.List()
|
||||||
|
if len(skills) == 0 {
|
||||||
|
m.entries = append(m.entries, newSlashEntry("No skills found. Add directories containing SKILL.md under "+skillsDirForDisplay(m.opts.Skills)+"."))
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
lines := []string{"Available skills:"}
|
||||||
|
for _, skill := range skills {
|
||||||
|
description := skill.Description
|
||||||
|
if description == "" {
|
||||||
|
description = "No description provided."
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("- `%s`: %s", skill.Name, description))
|
||||||
|
}
|
||||||
|
lines = append(lines, "\nType `/<name>` to load a skill into the conversation.")
|
||||||
|
m.entries = append(m.entries, newSlashEntry(strings.Join(lines, "\n")))
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
|
||||||
|
if catalog != nil && catalog.Dir() != "" {
|
||||||
|
return catalog.Dir()
|
||||||
|
}
|
||||||
|
dir, err := coreagent.SkillsDir()
|
||||||
|
if err != nil {
|
||||||
|
return "the Ollama skills directory"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// skillSlashInvocation parses "/<skill-name>" or "/<skill-name> <prompt>".
|
||||||
|
// It returns the skill name, any trailing prompt, and ok when the first token is
|
||||||
|
// a catalog skill. Built-in slash commands take precedence over same-named
|
||||||
|
// skills, so they are never claimed here.
|
||||||
|
func (m *chatModel) skillSlashInvocation(input string) (name, prompt string, ok bool) {
|
||||||
|
input = strings.TrimSpace(input)
|
||||||
|
if !strings.HasPrefix(input, "/") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
token, args, _ := strings.Cut(input, " ")
|
||||||
|
name = strings.TrimPrefix(token, "/")
|
||||||
|
if name == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if _, _, known := slashCommandInvocation(input); known {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if _, err := m.opts.Skills.Load(name); err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return name, strings.TrimSpace(args), true
|
||||||
|
}
|
||||||
|
|
||||||
func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
|
func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
|
||||||
if strings.TrimSpace(args) != "" {
|
if strings.TrimSpace(args) != "" {
|
||||||
m.status = "error"
|
m.status = "error"
|
||||||
@@ -165,6 +228,9 @@ func (m *chatModel) handleToolsCommand(args string) (tea.Model, tea.Cmd) {
|
|||||||
m.opts.ToolsDisabled = true
|
m.opts.ToolsDisabled = true
|
||||||
m.status = "tools off"
|
m.status = "tools off"
|
||||||
}
|
}
|
||||||
|
if m.opts.SystemPromptForModel != nil {
|
||||||
|
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
|
||||||
|
}
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,16 +1113,16 @@ func (m chatModel) completions() []chatCompletion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m chatModel) slashCompletions() []chatCompletion {
|
func (m chatModel) slashCompletions() []chatCompletion {
|
||||||
input := strings.TrimSpace(string(m.input))
|
rawInput := string(m.input)
|
||||||
|
input := strings.TrimSpace(rawInput)
|
||||||
if !strings.HasPrefix(input, "/") {
|
if !strings.HasPrefix(input, "/") {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if m.skillSlashPromptStarted(rawInput) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
commands := matchingSlashCommands(input)
|
commands := matchingSlashCommands(input)
|
||||||
if len(commands) == 0 {
|
|
||||||
return []chatCompletion{{label: "No matching commands"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
completions := make([]chatCompletion, 0, len(commands))
|
completions := make([]chatCompletion, 0, len(commands))
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
completions = append(completions, chatCompletion{
|
completions = append(completions, chatCompletion{
|
||||||
@@ -1065,9 +1131,45 @@ func (m chatModel) slashCompletions() []chatCompletion {
|
|||||||
description: command.description,
|
description: command.description,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
|
||||||
|
// completions so they are discoverable by typing.
|
||||||
|
if m.opts.Skills != nil {
|
||||||
|
prefix := strings.ToLower(input)
|
||||||
|
for _, skill := range m.opts.Skills.List() {
|
||||||
|
name := "/" + skill.Name
|
||||||
|
if !strings.HasPrefix(name, prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, known := slashCommandInvocation(name); known {
|
||||||
|
continue // built-in command wins; don't shadow it
|
||||||
|
}
|
||||||
|
description := skill.Description
|
||||||
|
if description == "" {
|
||||||
|
description = "No description provided."
|
||||||
|
}
|
||||||
|
completions = append(completions, chatCompletion{
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
description: description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(completions) == 0 {
|
||||||
|
return []chatCompletion{{label: "No matching commands"}}
|
||||||
|
}
|
||||||
return completions
|
return completions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m chatModel) skillSlashPromptStarted(input string) bool {
|
||||||
|
input = strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||||
|
end := strings.IndexFunc(input, unicode.IsSpace)
|
||||||
|
if end < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, _, ok := m.skillSlashInvocation(input[:end])
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
func matchingSlashCommands(input string) []chatSlashCommand {
|
func matchingSlashCommands(input string) []chatSlashCommand {
|
||||||
prefix := strings.ToLower(strings.TrimSpace(input))
|
prefix := strings.ToLower(strings.TrimSpace(input))
|
||||||
if prefix == "" {
|
if prefix == "" {
|
||||||
|
|||||||
+263
-9
@@ -43,7 +43,7 @@ func TestChatHelpCommandShowsV1Commands(t *testing.T) {
|
|||||||
t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content)
|
t.Fatalf("help output missing %q:\n%s", want, fm.entries[0].content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, removed := range []string{"/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
|
for _, removed := range []string{"/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||||
if strings.Contains(fm.entries[0].content, removed) {
|
if strings.Contains(fm.entries[0].content, removed) {
|
||||||
t.Fatalf("removed command %q should stay hidden from help:\n%s", removed, fm.entries[0].content)
|
t.Fatalf("removed command %q should stay hidden from help:\n%s", removed, fm.entries[0].content)
|
||||||
}
|
}
|
||||||
@@ -75,8 +75,8 @@ func TestChatNewCommandRepaintsFromTop(t *testing.T) {
|
|||||||
if m.flowPrintedLines != 0 {
|
if m.flowPrintedLines != 0 {
|
||||||
t.Fatalf("flowPrintedLines = %d, want 0", m.flowPrintedLines)
|
t.Fatalf("flowPrintedLines = %d, want 0", m.flowPrintedLines)
|
||||||
}
|
}
|
||||||
if m.approvalState.AllowAll() || m.opts.AllowAllTools || m.approvalState.Allows("edit") || m.permissionNotice != "" {
|
if m.approvalState.AllGranted() || m.opts.AllowAllTools || m.approvalState.Allows("edit") || m.permissionNotice != "" {
|
||||||
t.Fatalf("permissions were not reset: allowAll=%v opts=%v editAllowed=%v notice=%q", m.approvalState.AllowAll(), m.opts.AllowAllTools, m.approvalState.Allows("edit"), m.permissionNotice)
|
t.Fatalf("permissions were not reset: allowAll=%v opts=%v editAllowed=%v notice=%q", m.approvalState.AllGranted(), m.opts.AllowAllTools, m.approvalState.Allows("edit"), m.permissionNotice)
|
||||||
}
|
}
|
||||||
if msg := cmd(); msg == nil {
|
if msg := cmd(); msg == nil {
|
||||||
t.Fatal("repaint command returned nil")
|
t.Fatal("repaint command returned nil")
|
||||||
@@ -92,10 +92,10 @@ func TestChatNewCommandPreservesLaunchFullAccessDefault(t *testing.T) {
|
|||||||
|
|
||||||
updated, _ := m.handleSubmit()
|
updated, _ := m.handleSubmit()
|
||||||
fm := updated.(chatModel)
|
fm := updated.(chatModel)
|
||||||
if !fm.approvalState.AllowAll() || !fm.opts.AllowAllTools {
|
if !fm.approvalState.AllGranted() || !fm.opts.AllowAllTools {
|
||||||
t.Fatalf("full access default was not restored: allowAll=%v opts=%v", fm.approvalState.AllowAll(), fm.opts.AllowAllTools)
|
t.Fatalf("full access default was not restored: allowAll=%v opts=%v", fm.approvalState.AllGranted(), fm.opts.AllowAllTools)
|
||||||
}
|
}
|
||||||
fm.approvalState.SetAllowAll(false)
|
fm.approvalState.Set(false, nil)
|
||||||
if fm.approvalState.Allows("edit") {
|
if fm.approvalState.Allows("edit") {
|
||||||
t.Fatal("edit scope should be cleared")
|
t.Fatal("edit scope should be cleared")
|
||||||
}
|
}
|
||||||
@@ -419,6 +419,35 @@ func TestChatInputAcceptsSpace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChatCloudModelDefaultToolRoundsAreUnlimited(t *testing.T) {
|
||||||
|
const formerDefaultLimit = 100
|
||||||
|
client := &chatToolLoopClient{toolRounds: formerDefaultLimit + 1}
|
||||||
|
registry := &coreagent.Registry{}
|
||||||
|
registry.Register(chatTestTool{})
|
||||||
|
m := chatModel{
|
||||||
|
ctx: context.Background(),
|
||||||
|
opts: Options{
|
||||||
|
Model: "test:cloud",
|
||||||
|
Client: client,
|
||||||
|
Tools: registry,
|
||||||
|
AllowAllTools: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd := m.startRun("keep going")
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("startRun should start a cloud model run")
|
||||||
|
}
|
||||||
|
done := waitForRunDone(t, m.events)
|
||||||
|
if done.err != nil {
|
||||||
|
t.Fatalf("cloud run returned error: %v", done.err)
|
||||||
|
}
|
||||||
|
if client.calls != formerDefaultLimit+2 {
|
||||||
|
t.Fatalf("client calls = %d, want %d", client.calls, formerDefaultLimit+2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
|
func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
|
||||||
pasted := strings.Repeat("line\n", pastedTextPlaceholderMinLines-1) + "line"
|
pasted := strings.Repeat("line\n", pastedTextPlaceholderMinLines-1) + "line"
|
||||||
m := chatModel{
|
m := chatModel{
|
||||||
@@ -482,8 +511,204 @@ func TestInitialPromptHistoryLoadsFromMessages(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
skillDir := filepath.Join(dir, "release-notes")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := coreagent.DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := chatModel{opts: Options{Skills: catalog}, input: []rune("/skills")}
|
||||||
|
updated, cmd := m.handleSubmit()
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("/skills should not start a model run")
|
||||||
|
}
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "release-notes") {
|
||||||
|
t.Fatalf("/skills entries = %#v", m.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
m = chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes")}
|
||||||
|
updated, cmd = m.handleSubmit()
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("/<skill-name> should continue the chat with the loaded instructions")
|
||||||
|
}
|
||||||
|
m = updated.(chatModel)
|
||||||
|
events := m.events
|
||||||
|
for {
|
||||||
|
msg, ok := <-events
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill run closed before it finished")
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(msg)
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if _, ok := msg.(chatRunDoneMsg); ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.messages) != 4 {
|
||||||
|
t.Fatalf("synthetic messages = %#v", m.messages)
|
||||||
|
}
|
||||||
|
call := m.messages[1]
|
||||||
|
result := m.messages[2]
|
||||||
|
if call.Role != "assistant" || len(call.ToolCalls) != 1 || call.ToolCalls[0].Function.Name != "skill" || !strings.HasPrefix(call.ToolCalls[0].ID, "call_skill_") {
|
||||||
|
t.Fatalf("synthetic call = %#v", call)
|
||||||
|
}
|
||||||
|
if result.Role != "tool" || result.ToolCallID != call.ToolCalls[0].ID || !strings.Contains(result.Content, "Use concise bullets.") {
|
||||||
|
t.Fatalf("synthetic result = %#v", result)
|
||||||
|
}
|
||||||
|
entries := entriesFromMessages(m.messages)
|
||||||
|
if len(entries) != 3 || entries[1].toolID != call.ToolCalls[0].ID || entries[1].detail != "skill" || entries[1].args["name"] != "release-notes" {
|
||||||
|
t.Fatalf("round-trip entries = %#v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSlashCommandPromptBecomesUserMessage(t *testing.T) {
|
||||||
|
catalog := writeTestSkillCatalog(t)
|
||||||
|
m := chatModel{ctx: context.Background(), opts: Options{Model: "test", Skills: catalog, Client: chatTestClient{}}, input: []rune("/release-notes draft the v1.2 notes")}
|
||||||
|
updated, cmd := m.handleSubmit()
|
||||||
|
if cmd == nil {
|
||||||
|
t.Fatal("/<skill-name> <prompt> should start a run")
|
||||||
|
}
|
||||||
|
m = updated.(chatModel)
|
||||||
|
for {
|
||||||
|
msg, ok := <-m.events
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("skill run closed before it finished")
|
||||||
|
}
|
||||||
|
updated, _ = m.Update(msg)
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if _, ok := msg.(chatRunDoneMsg); ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.messages) < 1 || m.messages[0].Role != "user" || m.messages[0].Content != "draft the v1.2 notes" {
|
||||||
|
t.Fatalf("user message = %#v, want the prompt", m.messages[0])
|
||||||
|
}
|
||||||
|
// The skill still loads as a synthetic tool call right after the user turn.
|
||||||
|
if len(m.messages) < 3 || m.messages[1].Role != "assistant" || len(m.messages[1].ToolCalls) != 1 || m.messages[1].ToolCalls[0].Function.Name != "skill" {
|
||||||
|
t.Fatalf("synthetic skill call missing: %#v", m.messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChatSkillSubmitWhileActiveRunKeepsActiveState(t *testing.T) {
|
||||||
|
catalog := writeTestSkillCatalog(t)
|
||||||
|
for _, state := range []struct {
|
||||||
|
name string
|
||||||
|
running bool
|
||||||
|
compacting bool
|
||||||
|
}{
|
||||||
|
{name: "running", running: true},
|
||||||
|
{name: "compacting", compacting: true},
|
||||||
|
} {
|
||||||
|
t.Run(state.name, func(t *testing.T) {
|
||||||
|
events := make(chan tea.Msg)
|
||||||
|
cancel := func() {}
|
||||||
|
m := chatModel{
|
||||||
|
opts: Options{Skills: catalog},
|
||||||
|
input: []rune("/release-notes draft notes"),
|
||||||
|
running: state.running,
|
||||||
|
compacting: state.compacting,
|
||||||
|
events: events,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, cmd := m.handleSubmit()
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("skill submit should not start another run while active")
|
||||||
|
}
|
||||||
|
got := updated.(chatModel)
|
||||||
|
if got.events != events || got.cancel == nil || got.running != state.running || got.compacting != state.compacting {
|
||||||
|
t.Fatalf("active run state changed: %#v", got)
|
||||||
|
}
|
||||||
|
if string(got.input) != "/release-notes draft notes" {
|
||||||
|
t.Fatalf("input = %q, want skill invocation preserved", got.input)
|
||||||
|
}
|
||||||
|
if got.status != "wait for current response" {
|
||||||
|
t.Fatalf("status = %q", got.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestSkillCatalog(t *testing.T) *coreagent.SkillCatalog {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
skillDir := filepath.Join(dir, "release-notes")
|
||||||
|
if err := os.MkdirAll(skillDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: release-notes\ndescription: Draft release notes.\n---\nUse concise bullets."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
catalog, err := coreagent.DiscoverSkills(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return catalog
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
|
||||||
|
catalog := writeTestSkillCatalog(t)
|
||||||
|
m := chatModel{opts: Options{Skills: catalog}, input: []rune("/re")}
|
||||||
|
|
||||||
|
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
|
||||||
|
if !strings.Contains(lines, "/release-notes") || !strings.Contains(lines, "Draft release notes.") {
|
||||||
|
t.Fatalf("suggestions missing /release-notes: %q", lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
|
||||||
|
catalog := writeTestSkillCatalog(t)
|
||||||
|
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
|
||||||
|
t.Run(input, func(t *testing.T) {
|
||||||
|
m := chatModel{opts: Options{Skills: catalog}, input: []rune(input)}
|
||||||
|
if lines := m.completionLines(80); len(lines) != 0 {
|
||||||
|
t.Fatalf("completion lines = %#v, want none", lines)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
|
||||||
|
catalog := writeTestSkillCatalog(t)
|
||||||
|
m := &chatModel{opts: Options{Skills: catalog}}
|
||||||
|
|
||||||
|
if name, _, ok := m.skillSlashInvocation("/release-notes"); !ok || name != "release-notes" {
|
||||||
|
t.Fatalf("/release-notes = %q %v, want release-notes true", name, ok)
|
||||||
|
}
|
||||||
|
if name, prompt, ok := m.skillSlashInvocation("/release-notes draft notes"); !ok || name != "release-notes" || prompt != "draft notes" {
|
||||||
|
t.Fatalf("/release-notes draft notes = %q %q %v, want release-notes / draft notes / true", name, prompt, ok)
|
||||||
|
}
|
||||||
|
if _, _, ok := m.skillSlashInvocation("/no-such-skill"); ok {
|
||||||
|
t.Fatal("unknown skill should not resolve")
|
||||||
|
}
|
||||||
|
// A built-in command sharing a prefix must not be claimed as a skill.
|
||||||
|
if _, _, ok := m.skillSlashInvocation("/skills"); ok {
|
||||||
|
t.Fatal("/skills should resolve to the built-in, not a skill")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown slash input that is not a skill stays an unknown command.
|
||||||
|
m2 := chatModel{opts: Options{Skills: catalog}, input: []rune("/no-such-skill")}
|
||||||
|
updated, cmd := m2.handleSubmit()
|
||||||
|
if cmd != nil {
|
||||||
|
t.Fatal("unknown slash command should not start a run")
|
||||||
|
}
|
||||||
|
m2 = updated.(chatModel)
|
||||||
|
if len(m2.entries) != 1 || m2.entries[0].role != "error" || !strings.Contains(m2.entries[0].content, "Unknown command") {
|
||||||
|
t.Fatalf("entries = %#v, want unknown command", m2.entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
|
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
|
||||||
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
|
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||||
t.Run(command, func(t *testing.T) {
|
t.Run(command, func(t *testing.T) {
|
||||||
m := chatModel{input: []rune(command)}
|
m := chatModel{input: []rune(command)}
|
||||||
|
|
||||||
@@ -507,12 +732,12 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
view := stripANSI(m.View())
|
view := stripANSI(m.View())
|
||||||
for _, want := range []string{"/model", "/new", "/think", "/tools", "/compact"} {
|
for _, want := range []string{"/model", "/new", "/think", "/tools", "/skills"} {
|
||||||
if !strings.Contains(view, want) {
|
if !strings.Contains(view, want) {
|
||||||
t.Fatalf("view missing %s suggestion: %q", want, view)
|
t.Fatalf("view missing %s suggestion: %q", want, view)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/skills", "/verbose"} {
|
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
|
||||||
if strings.Contains(view, removed) {
|
if strings.Contains(view, removed) {
|
||||||
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
|
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
|
||||||
}
|
}
|
||||||
@@ -576,6 +801,35 @@ func TestChatToolsCommandTogglesToolRegistry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestChatToolsCommandRefreshesCapabilityAwareSystemPrompt(t *testing.T) {
|
||||||
|
registry := &coreagent.Registry{}
|
||||||
|
registry.Register(chatTestTool{})
|
||||||
|
m := chatModel{
|
||||||
|
ctx: context.Background(),
|
||||||
|
opts: Options{
|
||||||
|
Model: "test",
|
||||||
|
Tools: registry,
|
||||||
|
SystemPromptForModel: func(_ context.Context, _ string, _ *coreagent.Registry, disabled bool) string {
|
||||||
|
if disabled {
|
||||||
|
return "tools disabled"
|
||||||
|
}
|
||||||
|
return "tools enabled"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := m.handleToolsCommand("")
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if m.opts.SystemPrompt != "tools disabled" {
|
||||||
|
t.Fatalf("system prompt = %q, want disabled prompt", m.opts.SystemPrompt)
|
||||||
|
}
|
||||||
|
updated, _ = m.handleToolsCommand("")
|
||||||
|
m = updated.(chatModel)
|
||||||
|
if m.opts.SystemPrompt != "tools enabled" {
|
||||||
|
t.Fatalf("system prompt = %q, want enabled prompt", m.opts.SystemPrompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChatToolsCommandUsage(t *testing.T) {
|
func TestChatToolsCommandUsage(t *testing.T) {
|
||||||
m := chatModel{input: []rune("/tools off")}
|
m := chatModel{input: []rune("/tools off")}
|
||||||
|
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ func (m *chatModel) applyModelSelection(modelName string, persist bool) error {
|
|||||||
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, modelName)
|
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, modelName)
|
||||||
}
|
}
|
||||||
if m.opts.SystemPromptForModel != nil {
|
if m.opts.SystemPromptForModel != nil {
|
||||||
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, modelName, m.opts.Tools)
|
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, modelName, m.opts.Tools, m.opts.ToolsDisabled)
|
||||||
}
|
}
|
||||||
if m.opts.MultiModalForModel != nil {
|
if m.opts.MultiModalForModel != nil {
|
||||||
ctx := m.ctx
|
ctx := m.ctx
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ func TestChatModelPickerFiltersAndSwitchesModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return 262144
|
return 262144
|
||||||
},
|
},
|
||||||
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry) string {
|
SystemPromptForModel: func(ctx context.Context, model string, registry *coreagent.Registry, toolsDisabled bool) string {
|
||||||
if model != "qwen3.5:cloud" {
|
if model != "qwen3.5:cloud" {
|
||||||
t.Fatalf("system prompt model = %q, want qwen3.5:cloud", model)
|
t.Fatalf("system prompt model = %q, want qwen3.5:cloud", model)
|
||||||
}
|
}
|
||||||
@@ -397,7 +397,7 @@ func TestChatModelSwitchNextRunKeepsHistory(t *testing.T) {
|
|||||||
opts: Options{
|
opts: Options{
|
||||||
Model: "llama3.2",
|
Model: "llama3.2",
|
||||||
Client: client,
|
Client: client,
|
||||||
SystemPromptForModel: func(_ context.Context, model string, _ *coreagent.Registry) string {
|
SystemPromptForModel: func(_ context.Context, model string, _ *coreagent.Registry, _ bool) string {
|
||||||
return "system for " + model
|
return "system for " + model
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1084,9 +1084,9 @@ func toolActionPhrase(action string, count int) string {
|
|||||||
return "Fetched a URL"
|
return "Fetched a URL"
|
||||||
case "skill":
|
case "skill":
|
||||||
if plural {
|
if plural {
|
||||||
return fmt.Sprintf("Ran %d skills", count)
|
return fmt.Sprintf("Loaded %d skills", count)
|
||||||
}
|
}
|
||||||
return "Ran a skill"
|
return "Loaded a skill"
|
||||||
default:
|
default:
|
||||||
if plural {
|
if plural {
|
||||||
return fmt.Sprintf("Used %d tools", count)
|
return fmt.Sprintf("Used %d tools", count)
|
||||||
|
|||||||
@@ -1049,6 +1049,15 @@ func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToolActionPhraseLoadsSkills(t *testing.T) {
|
||||||
|
if got := toolActionPhrase("skill", 1); got != "Loaded a skill" {
|
||||||
|
t.Fatalf("single skill action = %q", got)
|
||||||
|
}
|
||||||
|
if got := toolActionPhrase("skill", 2); got != "Loaded 2 skills" {
|
||||||
|
t.Fatalf("multiple skill action = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
|
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
|
||||||
args := api.NewToolCallFunctionArguments()
|
args := api.NewToolCallFunctionArguments()
|
||||||
args.Set("command", "pwd")
|
args.Set("command", "pwd")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package chat
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,11 @@ type chatCaptureClient struct {
|
|||||||
requests []*api.ChatRequest
|
requests []*api.ChatRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type chatToolLoopClient struct {
|
||||||
|
calls int
|
||||||
|
toolRounds int
|
||||||
|
}
|
||||||
|
|
||||||
func (chatTestClient) Chat(ctx context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
func (chatTestClient) Chat(ctx context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -41,6 +47,26 @@ func (c *chatCaptureClient) Chat(ctx context.Context, req *api.ChatRequest, fn a
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *chatToolLoopClient) Chat(ctx context.Context, req *api.ChatRequest, fn api.ChatResponseFunc) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.calls++
|
||||||
|
if c.calls > c.toolRounds {
|
||||||
|
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", Content: "done"}, Done: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
args := api.NewToolCallFunctionArguments()
|
||||||
|
args.Set("value", "keep going")
|
||||||
|
return fn(api.ChatResponse{Message: api.Message{Role: "assistant", ToolCalls: []api.ToolCall{{
|
||||||
|
ID: fmt.Sprintf("call-%d", c.calls),
|
||||||
|
Function: api.ToolCallFunction{
|
||||||
|
Name: "fake_tool",
|
||||||
|
Arguments: args,
|
||||||
|
},
|
||||||
|
}}}})
|
||||||
|
}
|
||||||
|
|
||||||
func (chatTestTool) Name() string {
|
func (chatTestTool) Name() string {
|
||||||
return "fake_tool"
|
return "fake_tool"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user