Compare commits

...

5 Commits

Author SHA1 Message Date
Parth Sareen
fce745fe5e agent: import skills from coding agents (#17294) 2026-07-22 22:40:25 -07:00
Daniel Hiltgen
1fd1ccf7ad model: align Laguna with upstream llama.cpp (#17335)
Update llama.cpp to pick up upstream Laguna implementation and remove Ollama's local Laguna implementation. Retain a narrow Metal-only scaling workaround for routed-MoE prompt overflow.

Translate older Ollama GGUF attention-gate and SWA metadata names so existing models continue to load.
2026-07-22 17:09:18 -07:00
Michael Yang
efb7e3c55e docs: update retirements (#17289) 2026-07-22 14:24:11 -07:00
Daniel Hiltgen
b517b9bd01 model/parsers: finalize incomplete GLM tool calls (#17250)
The GLM parser buffered tool calls until it observed </tool_call>, but ignored the terminal done signal. If the model omitted or partially emitted the outer closing tag, Ollama returned a successful empty response instead of a tool call or an actionable error, leaving coding agents unable to continue.

On end-of-stream, finalize only structurally complete calls for declared tools with all required arguments. Complete calls missing only the outer delimiter now proceed through the existing parser, while genuinely truncated calls return an explicit error rather than being silently dropped.

Fixes #16497
2026-07-22 13:53:47 -07:00
Daniel Hiltgen
479664e7aa mlx update (#17332) 2026-07-22 13:36:49 -07:00
22 changed files with 1140 additions and 414 deletions

View File

@@ -1 +1 @@
b10069
b10091

View File

@@ -1 +1 @@
b7c3dd6d27f45b5365b08a840310187dc503f1db
33c03c486c34a7dadab5339563612c9933c4a406

View File

@@ -1,8 +1,10 @@
package agent
import (
"bytes"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
@@ -271,6 +273,354 @@ type skillRoot struct {
path string
}
// SkillImportResult describes one import attempt. Failed skills do not prevent
// other valid skills in the same source root from being imported.
type SkillImportResult struct {
Source string
SourceDir string
Destination string
Imported []string
Existing []string
Failures []SkillImportFailure
}
// SkillImportFailure identifies a source skill that was deliberately skipped.
// The destination is never changed for a failed skill.
type SkillImportFailure struct {
Name string
Err error
}
// ImportSkills imports skills from a conventional coding-agent source into the
// canonical Ollama skills directory. Supported sources are codex, claude, and
// pi. Existing skills are left untouched: an identical directory is reported
// as existing, and a differing one is reported as a conflict.
func ImportSkills(source string) (SkillImportResult, error) {
home, err := os.UserHomeDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve home directory: %w", err)
}
destination, err := SkillsDir()
if err != nil {
return SkillImportResult{}, fmt.Errorf("resolve Ollama skills directory: %w", err)
}
return importSkillsFromRoots(source, conventionalSkillImportRoots(home), destination)
}
func conventionalSkillImportRoots(home string) map[string]string {
return map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
}
}
func importSkillsFromRoots(source string, roots map[string]string, destination string) (SkillImportResult, error) {
source = strings.ToLower(strings.TrimSpace(source))
sourceDir, ok := roots[source]
if !ok {
return SkillImportResult{}, fmt.Errorf("unknown skill source %q", source)
}
return importSkillsFromDir(source, sourceDir, destination)
}
func importSkillsFromDir(source, sourceDir, destination string) (SkillImportResult, error) {
result := SkillImportResult{Source: source, SourceDir: sourceDir, Destination: destination}
info, err := os.Lstat(sourceDir)
if errors.Is(err, fs.ErrNotExist) {
return result, nil
}
if err != nil {
return result, fmt.Errorf("inspect %s skills directory: %w", source, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return result, fmt.Errorf("inspect %s skills directory: symlinks are not supported", source)
}
if !info.IsDir() {
return result, fmt.Errorf("inspect %s skills directory: not a directory", source)
}
entries, err := os.ReadDir(sourceDir)
if err != nil {
return result, fmt.Errorf("read %s skills directory: %w", source, err)
}
for _, entry := range entries {
name := entry.Name()
path := filepath.Join(sourceDir, name)
if entry.Type()&os.ModeSymlink != 0 {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("symlinked skill directories are not supported")})
continue
}
info, err := entry.Info()
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: fmt.Errorf("inspect source: %w", err)})
continue
}
if !info.IsDir() {
continue
}
if !skillName.MatchString(name) {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: errors.New("invalid skill directory name")})
continue
}
if err := validateImportSkill(path, name); err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
state, err := importSkillDirectory(path, filepath.Join(destination, name))
if err != nil {
result.Failures = append(result.Failures, SkillImportFailure{Name: name, Err: err})
continue
}
if state == skillImportExisting {
result.Existing = append(result.Existing, name)
} else {
result.Imported = append(result.Imported, name)
}
}
return result, nil
}
func validateImportSkill(dir, name string) error {
manifest := filepath.Join(dir, skillFilename)
info, err := os.Lstat(manifest)
if err != nil {
return fmt.Errorf("inspect %s: %w", skillFilename, err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
return fmt.Errorf("%s must be a regular, non-symlinked file", skillFilename)
}
if _, err := parseSkill(manifest, name); err != nil {
return err
}
return walkImportTree(dir, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
if info.IsDir() || path == dir {
return nil
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
}
return file.Close()
})
}
func walkImportTree(root string, visit func(string, fs.DirEntry, fs.FileInfo) error) error {
return filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("unsafe skill path %q", path)
}
if entry.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("symlinks may not be imported: %s", path)
}
info, err := entry.Info()
if err != nil {
return err
}
return visit(path, entry, info)
})
}
type skillImportState int
const (
skillImportCopied skillImportState = iota
skillImportExisting
)
func importSkillDirectory(source, destination string) (skillImportState, error) {
if info, err := os.Lstat(destination); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return 0, errors.New("destination exists but is not a regular directory")
}
same, err := sameImportTree(source, destination)
if err != nil {
return 0, fmt.Errorf("inspect existing destination: %w", err)
}
if same {
return skillImportExisting, nil
}
return 0, errors.New("destination skill already exists with different contents")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination: %w", err)
}
if err := ensureImportDestination(filepath.Dir(destination)); err != nil {
return 0, err
}
stage, err := os.MkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".import-")
if err != nil {
return 0, fmt.Errorf("create import staging directory: %w", err)
}
defer os.RemoveAll(stage)
if err := copyImportTree(source, stage); err != nil {
return 0, err
}
if _, err := os.Lstat(destination); err == nil {
return 0, errors.New("destination skill was created during import")
} else if !errors.Is(err, fs.ErrNotExist) {
return 0, fmt.Errorf("inspect destination before install: %w", err)
}
if err := os.Rename(stage, destination); err != nil {
return 0, fmt.Errorf("install imported skill: %w", err)
}
return skillImportCopied, nil
}
func ensureImportDestination(dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create Ollama skills directory: %w", err)
}
info, err := os.Lstat(dir)
if err != nil {
return fmt.Errorf("inspect Ollama skills directory: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return errors.New("Ollama skills directory must be a regular, non-symlinked directory")
}
return nil
}
func copyImportTree(source, destination string) error {
return walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
target := destination
if rel != "." {
target = filepath.Join(destination, rel)
}
if info.IsDir() {
if rel == "." {
return nil
}
return os.Mkdir(target, info.Mode().Perm())
}
if !info.Mode().IsRegular() {
return fmt.Errorf("only regular files may be imported: %s", path)
}
return copyImportFile(path, target, info.Mode().Perm())
})
}
func copyImportFile(source, destination string, mode fs.FileMode) error {
in, err := os.Open(source)
if err != nil {
return fmt.Errorf("read %s: %w", source, err)
}
defer in.Close()
out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return fmt.Errorf("create %s: %w", destination, err)
}
_, copyErr := io.Copy(out, in)
closeErr := out.Close()
if copyErr != nil {
return fmt.Errorf("copy %s: %w", source, copyErr)
}
if closeErr != nil {
return fmt.Errorf("write %s: %w", destination, closeErr)
}
return nil
}
func sameImportTree(source, destination string) (bool, error) {
seen := make(map[string]struct{})
same := true
err := walkImportTree(source, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(source, path)
if err != nil {
return err
}
seen[rel] = struct{}{}
other := destination
if rel != "." {
other = filepath.Join(destination, rel)
}
otherInfo, err := os.Lstat(other)
if errors.Is(err, fs.ErrNotExist) {
same = false
return nil
}
if err != nil {
return err
}
if otherInfo.Mode()&os.ModeSymlink != 0 || otherInfo.IsDir() != info.IsDir() || (!info.IsDir() && !otherInfo.Mode().IsRegular()) {
same = false
return nil
}
if info.Mode().IsRegular() {
equal, err := sameImportFile(path, other)
if err != nil {
return err
}
if !equal {
same = false
}
}
return nil
})
if err != nil || !same {
return same, err
}
err = walkImportTree(destination, func(path string, entry fs.DirEntry, info fs.FileInfo) error {
rel, err := filepath.Rel(destination, path)
if err != nil {
return err
}
if _, ok := seen[rel]; !ok {
same = false
}
return nil
})
return same, err
}
func sameImportFile(first, second string) (bool, error) {
a, err := os.Open(first)
if err != nil {
return false, err
}
defer a.Close()
b, err := os.Open(second)
if err != nil {
return false, err
}
defer b.Close()
left := make([]byte, 32*1024)
right := make([]byte, len(left))
for {
n, errA := a.Read(left)
m, errB := b.Read(right)
if n != m || !bytes.Equal(left[:n], right[:m]) {
return false, nil
}
if errA == io.EOF && errB == io.EOF {
return true, nil
}
if errA != nil && errA != io.EOF {
return false, errA
}
if errB != nil && errB != io.EOF {
return false, errB
}
if errA == io.EOF || errB == io.EOF {
return false, nil
}
}
}
// defaultSkillRoots returns skill directories ordered lowest- to
// highest-precedence. Non-existent directories are scanned harmlessly
// (DiscoverSkills skips them).

View File

@@ -21,6 +21,21 @@ func writeCatalogSkill(t *testing.T, dir, name, content string) {
}
}
func writeImportFixtureSkill(t *testing.T, dir string) {
t.Helper()
contents, err := os.ReadFile(filepath.Join("testdata", "import", "release-notes", skillFilename))
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "release-notes", skillFilename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, contents, 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.")
@@ -291,3 +306,187 @@ func TestSkillContentListsDirectoryAndResources(t *testing.T) {
t.Fatalf("content missing resource listing: %q", content)
}
}
func TestImportSkillsCopiesFixtureAndIsIdempotent(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeImportFixtureSkill(t, source)
writeCatalogSkill(t, source, "broken", "---\nname: another-skill\ndescription: Deliberately invalid.\n---\nIgnore this.")
if err := os.MkdirAll(filepath.Join(source, "release-notes", "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "references", "style.txt"), []byte("Keep it short.\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(source, "release-notes", "scripts"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "release-notes", "scripts", "prepare.sh"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "ignored.md"), []byte("Ignored root file.\n"), 0o644); err != nil {
t.Fatal(err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Imported, ","), "release-notes"; got != want {
t.Fatalf("imported = %q, want %q", got, want)
}
catalog, err := DiscoverSkills(destination)
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load("release-notes")
if err != nil || skill.Description != "Draft concise release notes." {
t.Fatalf("imported skill = %#v, %v", skill, err)
}
if got := len(result.Failures); got != 1 || result.Failures[0].Name != "broken" {
t.Fatalf("failures = %#v, want broken fixture failure", result.Failures)
}
for _, file := range []string{skillFilename, filepath.Join("references", "style.txt"), filepath.Join("scripts", "prepare.sh")} {
if _, err := os.Stat(filepath.Join(destination, "release-notes", file)); err != nil {
t.Fatalf("imported fixture file %q: %v", file, err)
}
}
result, err = importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if got, want := strings.Join(result.Existing, ","), "release-notes"; got != want {
t.Fatalf("existing = %q, want %q", got, want)
}
if len(result.Imported) != 0 {
t.Fatalf("repeated import copied skills: %#v", result.Imported)
}
}
func TestImportSkillsLeavesConflictsAndUnsafeSourcesUntouched(t *testing.T) {
source := t.TempDir()
destination := t.TempDir()
writeCatalogSkill(t, source, "release-notes", "source instructions")
writeCatalogSkill(t, destination, "release-notes", "existing instructions")
writeCatalogSkill(t, source, "nested-link", "safe manifest")
if err := os.Symlink(filepath.Join(source, "release-notes", skillFilename), filepath.Join(source, "nested-link", "reference")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
if err := os.Symlink(filepath.Join(source, "release-notes"), filepath.Join(source, "linked-skill")); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, destination)
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 {
t.Fatalf("unexpected successful import: %#v", result)
}
if got, err := os.ReadFile(filepath.Join(destination, "release-notes", skillFilename)); err != nil || !strings.Contains(string(got), "existing instructions") {
t.Fatalf("conflicting destination changed: %q, %v", got, err)
}
failed := make(map[string]bool)
for _, failure := range result.Failures {
failed[failure.Name] = true
}
for _, name := range []string{"release-notes", "nested-link", "linked-skill"} {
if !failed[name] {
t.Fatalf("missing failure for %q: %#v", name, result.Failures)
}
}
}
func TestImportSkillsRejectsSymlinkedRoot(t *testing.T) {
root := t.TempDir()
source := filepath.Join(t.TempDir(), "codex-skills")
if err := os.Symlink(root, source); err != nil {
t.Skipf("symlink not supported: %v", err)
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err == nil || !strings.Contains(err.Error(), "symlinks are not supported") {
t.Fatalf("symlinked root error = %v", err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("symlinked root result = %#v", result)
}
}
func TestImportSkillsMissingRootAndConfiguredRoots(t *testing.T) {
result, err := importSkillsFromDir("codex", filepath.Join(t.TempDir(), "missing"), t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Imported) != 0 || len(result.Existing) != 0 || len(result.Failures) != 0 {
t.Fatalf("missing root result = %#v", result)
}
destination := t.TempDir()
rootBase := t.TempDir()
roots := map[string]string{
"codex": filepath.Join(rootBase, "codex"),
"claude": filepath.Join(rootBase, "claude"),
"pi": filepath.Join(rootBase, "pi"),
}
for _, test := range []struct {
source string
root string
name string
}{
{source: "codex", root: roots["codex"], name: "from-codex"},
{source: "claude", root: roots["claude"], name: "from-claude"},
{source: "pi", root: roots["pi"], name: "from-pi"},
} {
t.Run(test.source, func(t *testing.T) {
writeCatalogSkill(t, test.root, test.name, "from "+test.source)
result, err = importSkillsFromRoots(test.source, roots, destination)
if err != nil {
t.Fatal(err)
}
if result.SourceDir != test.root {
t.Fatalf("source dir = %q, want %q", result.SourceDir, test.root)
}
if _, err := os.Stat(filepath.Join(destination, test.name, skillFilename)); err != nil {
t.Fatalf("conventional source was not imported: %v", err)
}
})
}
if _, err := importSkillsFromRoots("unknown", roots, destination); err == nil || !strings.Contains(err.Error(), "unknown skill source") {
t.Fatalf("unknown source error = %v", err)
}
}
func TestConventionalSkillImportRoots(t *testing.T) {
home := t.TempDir()
roots := conventionalSkillImportRoots(home)
for source, want := range map[string]string{
"codex": filepath.Join(home, ".codex", "skills"),
"claude": filepath.Join(home, ".claude", "skills"),
"pi": filepath.Join(home, ".pi", "agent", "skills"),
} {
if got := roots[source]; got != want {
t.Fatalf("%s root = %q, want %q", source, got, want)
}
}
}
func TestImportSkillsRejectsUnreadableManifest(t *testing.T) {
source := t.TempDir()
writeCatalogSkill(t, source, "private", "do not read")
manifest := filepath.Join(source, "private", skillFilename)
if err := os.Chmod(manifest, 0); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chmod(manifest, 0o644) })
if _, err := os.ReadFile(manifest); err == nil {
t.Skip("test user can read a mode-000 file")
}
result, err := importSkillsFromDir("codex", source, t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(result.Failures) != 1 || result.Failures[0].Name != "private" {
t.Fatalf("failures = %#v", result.Failures)
}
}

View File

@@ -0,0 +1,8 @@
---
name: release-notes
description: Draft concise release notes.
---
# Release notes
Use short bullets.

View File

@@ -83,12 +83,20 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return agentContextWindowForModel(ctx, client, model, fallback)
}
skillCatalog, err := coreagent.LoadDefaultSkills(cwd)
if err != nil {
return fmt.Errorf("load agent skills: %w", err)
var skillCatalog *coreagent.SkillCatalog
reloadSkills := func() (*coreagent.SkillCatalog, error) {
catalog, err := coreagent.LoadDefaultSkills(cwd)
if err != nil {
return nil, err
}
for _, diagnostic := range catalog.Diagnostics() {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
}
skillCatalog = catalog
return catalog, nil
}
for _, diagnostic := range skillCatalog.Diagnostics() {
fmt.Fprintf(os.Stderr, "\033[1mwarning:\033[0m ignored invalid agent skill: %v\n", diagnostic)
if _, err := reloadSkills(); err != nil {
return fmt.Errorf("load agent skills: %w", err)
}
var registry *coreagent.Registry
registryForModel := func(ctx context.Context, model string) *coreagent.Registry {
@@ -99,7 +107,7 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
}
systemPrompt := agentSystemPromptWithWorkingDir(opts.Model, opts.System, agentSkillSystemContext(skillCatalog, registry, opts.ToolsDisabled), cwd)
_, err = agentchat.Run(cmd.Context(), agentchat.Options{
_, err := agentchat.Run(cmd.Context(), agentchat.Options{
Model: opts.Model,
Client: client,
Tools: registry,
@@ -118,6 +126,8 @@ func GenerateAgentTUI(cmd *cobra.Command, client *api.Client, opts agentTUIOptio
return agentSystemPromptWithWorkingDir(model, agentSystemFromShow(ctx, client, model), agentSkillSystemContext(skillCatalog, registry, toolsDisabled), cwd)
},
Skills: skillCatalog,
ImportSkills: coreagent.ImportSkills,
ReloadSkills: reloadSkills,
SystemPrompt: systemPrompt,
WorkingDir: cwd,
Format: opts.Format,

View File

@@ -55,6 +55,8 @@ type Options struct {
Client coreagent.ChatClient
Tools *coreagent.Registry
Skills *coreagent.SkillCatalog
ImportSkills func(string) (coreagent.SkillImportResult, error)
ReloadSkills func() (*coreagent.SkillCatalog, error)
ToolRegistryForModel func(context.Context, string) *coreagent.Registry
ToolsDisabled bool
MultiModalForModel func(context.Context, string) bool

View File

@@ -55,7 +55,7 @@ var chatSlashCommands = []chatSlashCommand{
{name: "/new", description: "start a new chat"},
{name: "/think", description: "set thinking mode"},
{name: "/tools", description: "toggle tools on or off"},
{name: "/skills", description: "list available skills"},
{name: "/skills", usage: "/skills [import codex|claude|pi]", description: "list or import skills"},
{name: "/compact", description: "summarize older context"},
{name: "/help", description: "show commands", aliases: []string{"/?"}},
{name: "/bye", description: "exit", aliases: []string{"/exit"}},
@@ -63,6 +63,12 @@ var chatSlashCommands = []chatSlashCommand{
{name: "/save", usage: "/save <filename>", description: "save request JSON; saved as <filename>.json"},
}
var skillsImportCompletions = []chatCompletion{
{value: "/skills import codex", label: "/skills import codex", description: "import from ~/.codex/skills"},
{value: "/skills import claude", label: "/skills import claude", description: "import from ~/.claude/skills"},
{value: "/skills import pi", label: "/skills import pi", description: "import from ~/.pi/agent/skills"},
}
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
m.syncInputPlaceholders()
input := strings.TrimSpace(string(m.input))
@@ -159,8 +165,10 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
}
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"}))
if fields := strings.Fields(args); len(fields) == 2 && fields[0] == "import" {
return m.handleSkillsImport(fields[1])
} else if len(fields) != 0 {
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: "usage: /skills [import codex|claude|pi]"}))
return *m, nil
}
skills := m.opts.Skills.List()
@@ -181,6 +189,61 @@ func (m *chatModel) handleSkillsCommand(args string) (tea.Model, tea.Cmd) {
return *m, nil
}
func (m *chatModel) handleSkillsImport(source string) (tea.Model, tea.Cmd) {
importSkills := m.opts.ImportSkills
if importSkills == nil {
importSkills = coreagent.ImportSkills
}
result, err := importSkills(source)
if err != nil {
m.status = "error"
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("Could not import %s skills: %v", source, err)}))
return *m, nil
}
if len(result.Imported) != 0 || len(result.Existing) != 0 {
reload := m.opts.ReloadSkills
if reload == nil {
reload = func() (*coreagent.SkillCatalog, error) {
return coreagent.LoadDefaultSkills(m.currentWorkingDir())
}
}
catalog, err := reload()
if err != nil {
m.status = "error"
m.entries = append(m.entries, newChatEntry(chatEntry{role: "error", content: fmt.Sprintf("%s\n\nCould not reload skills: %v", skillsImportSummary(result), err)}))
return *m, nil
}
m.opts.Skills = catalog
if m.opts.ToolRegistryForModel != nil && m.opts.Model != "" {
m.opts.Tools = m.opts.ToolRegistryForModel(m.ctx, m.opts.Model)
}
if m.opts.SystemPromptForModel != nil {
m.opts.SystemPrompt = m.opts.SystemPromptForModel(m.ctx, m.opts.Model, m.opts.Tools, m.opts.ToolsDisabled)
}
m.status = "skills reloaded"
}
m.entries = append(m.entries, newSlashEntry(skillsImportSummary(result)))
return *m, nil
}
func skillsImportSummary(result coreagent.SkillImportResult) string {
if len(result.Imported) == 0 && len(result.Existing) == 0 && len(result.Failures) == 0 {
return fmt.Sprintf("No %s skills found at %s.", result.Source, result.SourceDir)
}
var lines []string
if len(result.Imported) != 0 {
lines = append(lines, fmt.Sprintf("Imported %d skill%s from %s.", len(result.Imported), pluralSuffix(len(result.Imported)), result.SourceDir))
}
if len(result.Existing) != 0 {
lines = append(lines, "Already present (left unchanged): "+strings.Join(result.Existing, ", ")+".")
}
for _, failure := range result.Failures {
lines = append(lines, fmt.Sprintf("Skipped %s: %v.", failure.Name, failure.Err))
}
return strings.Join(lines, "\n")
}
func skillsDirForDisplay(catalog *coreagent.SkillCatalog) string {
if catalog != nil && catalog.Dir() != "" {
return catalog.Dir()
@@ -1117,13 +1180,16 @@ func (m chatModel) completions() []chatCompletion {
func (m chatModel) slashCompletions() []chatCompletion {
rawInput := string(m.input)
input := strings.TrimSpace(rawInput)
input := strings.TrimLeftFunc(rawInput, unicode.IsSpace)
if !strings.HasPrefix(input, "/") {
return nil
}
if m.skillSlashPromptStarted(rawInput) {
return nil
}
if completions := matchingSkillsImportCompletions(input); completions != nil {
return completions
}
commands := matchingSlashCommands(input)
completions := make([]chatCompletion, 0, len(commands))
@@ -1134,6 +1200,13 @@ func (m chatModel) slashCompletions() []chatCompletion {
description: command.description,
})
}
if strings.EqualFold(input, "/skills") {
completions = append(completions, chatCompletion{
value: "/skills import",
label: "/skills import",
description: "import skills from Codex, Claude, or Pi",
})
}
// Each catalog skill is also invocable as "/<skill-name>"; surface them as
// completions so they are discoverable by typing.
if m.opts.Skills != nil {
@@ -1163,6 +1236,38 @@ func (m chatModel) slashCompletions() []chatCompletion {
return completions
}
func matchingSkillsImportCompletions(input string) []chatCompletion {
const importCommand = "/skills import"
lower := strings.ToLower(input)
if lower == "/skills" {
return nil // Preserve Enter on /skills as the listing command.
}
if !strings.HasPrefix(lower, "/skills ") {
return nil
}
if strings.HasPrefix(importCommand, lower) {
return []chatCompletion{{
value: importCommand,
label: importCommand,
description: "import skills from Codex, Claude, or Pi",
}}
}
if !strings.HasPrefix(lower, importCommand) {
return nil
}
prefix := strings.TrimSpace(strings.TrimPrefix(lower, importCommand))
completions := make([]chatCompletion, 0, len(skillsImportCompletions))
for _, completion := range skillsImportCompletions {
if strings.HasPrefix(strings.TrimPrefix(completion.value, importCommand+" "), prefix) {
completions = append(completions, completion)
}
}
if len(completions) == 0 {
return []chatCompletion{{label: "No matching skill sources"}}
}
return completions
}
func (m chatModel) skillSlashPromptStarted(input string) bool {
input = strings.TrimLeftFunc(input, unicode.IsSpace)
end := strings.IndexFunc(input, unicode.IsSpace)

View File

@@ -570,6 +570,76 @@ func TestSkillCommandsListAndPersistSyntheticToolCall(t *testing.T) {
}
}
func TestSkillsImportReloadsCatalogRegistryAndSystemPrompt(t *testing.T) {
before := writeTestSkillCatalog(t)
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "from-codex"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "from-codex", "SKILL.md"), []byte("---\nname: from-codex\ndescription: Imported skill.\n---\nImported instructions."), 0o644); err != nil {
t.Fatal(err)
}
after, err := coreagent.DiscoverSkills(dir)
if err != nil {
t.Fatal(err)
}
registry := &coreagent.Registry{}
var reloaded, rebuilt, prompted bool
m := chatModel{
ctx: context.Background(),
opts: Options{
Model: "test",
Skills: before,
ImportSkills: func(source string) (coreagent.SkillImportResult, error) {
if source != "codex" {
t.Fatalf("source = %q", source)
}
return coreagent.SkillImportResult{Source: source, SourceDir: "/source", Imported: []string{"from-codex"}}, nil
},
ReloadSkills: func() (*coreagent.SkillCatalog, error) {
reloaded = true
return after, nil
},
ToolRegistryForModel: func(context.Context, string) *coreagent.Registry {
rebuilt = true
return registry
},
SystemPromptForModel: func(_ context.Context, _ string, got *coreagent.Registry, _ bool) string {
prompted = got == registry
return after.SystemContext()
},
},
input: []rune("/skills import codex"),
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("skills import should not start a model run")
}
m = updated.(chatModel)
if !reloaded || !rebuilt || !prompted {
t.Fatalf("reload=%v rebuilt=%v prompted=%v", reloaded, rebuilt, prompted)
}
if m.opts.Skills != after || m.opts.Tools != registry || !strings.Contains(m.opts.SystemPrompt, "from-codex") {
t.Fatalf("reloaded options = %#v", m.opts)
}
if m.status != "skills reloaded" || len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Imported 1 skill") {
t.Fatalf("import result = status %q entries %#v", m.status, m.entries)
}
}
func TestSkillsImportUsage(t *testing.T) {
m := chatModel{input: []rune("/skills import")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("invalid skills import should not start a model run")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "usage: /skills [import codex|claude|pi]") {
t.Fatalf("entries = %#v", m.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")}
@@ -665,6 +735,31 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
}
}
func TestSkillsImportSlashCompletions(t *testing.T) {
for _, test := range []struct {
input string
want []string
}{
{input: "/skills", want: []string{"/skills", "/skills import"}},
{input: "/skills impo", want: []string{"/skills import"}},
{input: "/skills import ", want: []string{"/skills import codex", "/skills import claude", "/skills import pi"}},
{input: "/skills import c", want: []string{"/skills import codex", "/skills import claude"}},
{input: "/skills import pi", want: []string{"/skills import pi"}},
} {
t.Run(test.input, func(t *testing.T) {
m := chatModel{input: []rune(test.input)}
completions := m.slashCompletions()
got := make([]string, 0, len(completions))
for _, completion := range completions {
got = append(got, completion.value)
}
if strings.Join(got, "\n") != strings.Join(test.want, "\n") {
t.Fatalf("completions = %#v, want %#v", got, test.want)
}
})
}
}
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
catalog := writeTestSkillCatalog(t)
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {

View File

@@ -244,26 +244,33 @@ Ollama Cloud model retirement does not affect local models.
| Retirement date | Model | Recommended alternative |
| --- | --- | --- |
| July 15, 2026 | `deepseek-v3.1:671b` | `deepseek-v4-flash` |
| July 15, 2026 | `deepseek-v3.2` | `deepseek-v4-flash` |
| July 15, 2026 | `devstral-2:123b` | `mistral-large-3:675b` |
| July 15, 2026 | `devstral-small-2:24b` | |
| July 15, 2026 | `ministral-3:14b` | |
| July 15, 2026 | `ministral-3:3b` | |
| July 15, 2026 | `ministral-3:8b` | |
| July 15, 2026 | `gemini-3-flash-preview` | `minimax-m3` |
| July 15, 2026 | `gemma3:12b` | `gemma4:31b` |
| July 15, 2026 | `gemma3:27b` | `gemma4:31b` |
| July 15, 2026 | `gemma3:4b` | `gemma4:31b` |
| July 15, 2026 | `glm-4.7` | `glm-5.2` |
| July 15, 2026 | `glm-5` | `glm-5.2` |
| July 15, 2026 | `minimax-m2.1` | `minimax-m3` |
| July 15, 2026 | `qwen3-coder-next` | `qwen3.5:397b` |
| July 15, 2026 | `qwen3-coder:480b` | `qwen3.5:397b` |
| July 31, 2026 | `minimax-m2.5` | `minimax-m2.7` |
| July 31, 2026 | `kimi-k2.5` | `kimi-k2.6` |
### Past retirements
<AccordionGroup>
<Accordion title="July 15, 2026">
| Model | Recommended alternative |
| --- | --- |
| `deepseek-v3.1:671b` | `deepseek-v4-flash` |
| `deepseek-v3.2` | `deepseek-v4-flash` |
| `devstral-2:123b` | `mistral-large-3:675b` |
| `devstral-small-2:24b` | |
| `ministral-3:14b` | |
| `ministral-3:3b` | |
| `ministral-3:8b` | |
| `gemini-3-flash-preview` | `minimax-m3` |
| `gemma3:12b` | `gemma4:31b` |
| `gemma3:27b` | `gemma4:31b` |
| `gemma3:4b` | `gemma4:31b` |
| `glm-4.7` | `glm-5.2` |
| `glm-5` | `glm-5.2` |
| `minimax-m2.1` | `minimax-m3` |
| `qwen3-coder-next` | `qwen3.5:397b` |
| `qwen3-coder:480b` | `qwen3.5:397b` |
</Accordion>
<Accordion title="June 30, 2026">
| Model | Recommended alternative |
| --- | --- |

View File

@@ -11,8 +11,29 @@ import (
"github.com/ollama/ollama/api"
)
// runThinkingEnabled verifies that when thinking is requested, the model
// produces both thinking and content output without leaking raw channel tags.
var rawThinkingProtocolTags = []string{
"<|channel>",
"<channel|>",
"<think>",
"</think>",
"<assistant>",
"</assistant>",
"<tool_call>",
"</tool_call>",
}
func rejectRawThinkingProtocolTags(t *testing.T, field, value string) {
t.Helper()
for _, tag := range rawThinkingProtocolTags {
if strings.Contains(value, tag) {
t.Errorf("%s contains raw protocol tag %q: %s", field, tag, value)
}
}
}
// runThinkingEnabled verifies that thinking-capable models honor an explicit
// thinking request, complete a reasoning trace, and return the final answer
// without leaking raw channel tags.
func runThinkingEnabled(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
@@ -33,12 +54,15 @@ func runThinkingEnabled(t *testing.T) {
Stream: &stream,
Think: &think,
Messages: []api.Message{
{Role: "user", Content: "What is 12 * 15? Think step by step."},
{Role: "user", Content: "What is 12 multiplied by 15? Give the final answer."},
},
Options: map[string]any{
"temperature": 0,
"seed": 42,
"num_predict": 512,
// Deep-thinking models can use several thousand tokens on
// simple problems before producing their final answer. Keep
// this high enough to verify a natural stop, not truncation.
"num_predict": 8192,
},
}
@@ -61,22 +85,17 @@ func runThinkingEnabled(t *testing.T) {
if thinking == "" {
t.Error("expected non-empty thinking output when thinking is enabled")
}
// The answer (180) should appear in thinking, content, or both.
// Some models put everything in thinking and leave content empty
// if they hit the token limit while still thinking.
combined := thinking + " " + content
if !strings.Contains(combined, "180") {
t.Errorf("expected '180' in thinking or content, got thinking=%q content=%q", thinking, content)
if content == "" {
t.Error("expected non-empty final content after thinking")
} else if !strings.Contains(content, "180") {
t.Errorf("expected final answer 180, got content=%q", content)
}
if response.DoneReason != "stop" {
t.Errorf("expected completed response, got done reason %q", response.DoneReason)
}
// Neither thinking nor content should contain raw channel tags
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
t.Errorf("content contains raw channel tags: %s", content)
}
if strings.Contains(thinking, "<|channel>") || strings.Contains(thinking, "<channel|>") {
t.Errorf("thinking contains raw channel tags: %s", thinking)
}
rejectRawThinkingProtocolTags(t, "content", content)
rejectRawThinkingProtocolTags(t, "thinking", thinking)
t.Logf("thinking (%d chars): %.100s...", len(thinking), thinking)
t.Logf("content (%d chars): %s", len(content), content)
@@ -84,7 +103,7 @@ func runThinkingEnabled(t *testing.T) {
}
}
// runThinkingSuppressed verifies that when thinking is NOT requested,
// runThinkingSuppressed verifies that when thinking is explicitly disabled,
// the model does not leak thinking/channel content into the response.
func runThinkingSuppressed(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
@@ -100,10 +119,11 @@ func runThinkingSuppressed(t *testing.T) {
pullOrSkip(ctx, t, client, modelName)
stream := false
think := api.ThinkValue{Value: false}
req := api.ChatRequest{
Model: modelName,
Stream: &stream,
// Think is nil — thinking not requested
Think: &think,
Messages: []api.Message{
{Role: "user", Content: "What is the capital of Japan? Answer in one word."},
},
@@ -129,24 +149,16 @@ func runThinkingSuppressed(t *testing.T) {
content := response.Message.Content
thinking := response.Message.Thinking
// The answer should appear in content or thinking
combined := content + " " + thinking
if !strings.Contains(combined, "Tokyo") {
t.Errorf("expected 'Tokyo' in content or thinking, got content=%q thinking=%q", content, thinking)
// With thinking disabled, the answer must be returned as content.
if !strings.Contains(content, "Tokyo") {
t.Errorf("expected 'Tokyo' in content, got content=%q thinking=%q", content, thinking)
}
// Content must NOT contain channel/thinking tags
if strings.Contains(content, "<|channel>") || strings.Contains(content, "<channel|>") {
t.Errorf("content contains leaked channel tags when thinking not requested: %s", content)
}
if strings.Contains(content, "thought") && strings.Contains(content, "<channel|>") {
t.Errorf("content contains leaked thinking block: %s", content)
}
rejectRawThinkingProtocolTags(t, "content", content)
rejectRawThinkingProtocolTags(t, "thinking", thinking)
// Thinking field should ideally be empty when not requested.
// Some small models may still produce thinking output; log but don't fail.
if thinking != "" {
t.Logf("WARNING: model produced thinking output when not requested (%d chars): %.100s...", len(thinking), thinking)
t.Errorf("expected empty thinking when thinking is disabled, got %q", thinking)
}
t.Logf("content: %s", content)

View File

@@ -88,6 +88,7 @@ This table tracks the dispatch surface. Keep it brief; the handler comments in
| `deepseekocr` | Maps to `deepseek2-ocr`, injects missing OCR/MoE metadata, and hides embedded SAM/vision/projector tensors. | DeepSeek OCR projector translation. |
| `glmocr` | Maps GLM OCR metadata/tensors to the llama.cpp-compatible view. | GLM OCR projector translation. |
| `glm4moelite` | Maps GLM-4.7 Flash MLA metadata to the `deepseek2` path and fixes special-token metadata. | n/a |
| `laguna` | Renames legacy attention-gate tensors and SWA RoPE metadata to current llama.cpp names. | n/a |
| `nemotron_h_moe` | Fixes latent-FFN variants and hides MTP tensors. | n/a |
| `nemotron_h_omni` | Selects the Nemotron text loader and hides audio/vision/projector tensors from the text loader. | Nemotron V2 VL projector translation; audio remains disabled. |
| `llama` with Llama 3 markers | Fixes Llama 3 tokenizer metadata. | n/a |

View File

@@ -118,6 +118,43 @@ void fix_glm4moelite_eog_token_ids(gguf_context * meta) {
}
}
// =========================================================================
// laguna (text side)
// =========================================================================
bool detect_ollama_laguna(const gguf_context * meta, const ggml_context * ctx) {
const int64_t arch_kid = gguf_find_key(meta, "general.architecture");
if (arch_kid < 0 || std::strcmp(gguf_get_val_str(meta, arch_kid), "laguna") != 0) return false;
return has_key(meta, "laguna.rope.swa.dimension_count")
|| has_key(meta, "laguna.rope.swa.freq_base")
|| ggml_get_tensor(const_cast<ggml_context *>(ctx), "blk.0.attn_g.weight") != nullptr;
}
void handle_laguna(gguf_context * meta, ggml_context * ctx) {
if (!detect_ollama_laguna(meta, ctx)) return;
OLLAMA_COMPAT_LOG_INFO("%s: detected Ollama-format laguna GGUF; applying compatibility fixes\n", __func__);
copy_u32_kv(meta, "laguna.rope.swa.dimension_count", "laguna.rope.dimension_count_swa");
copy_f32_kv(meta, "laguna.rope.swa.freq_base", "laguna.rope.freq_base_swa");
std::vector<std::pair<std::string, std::string>> renames;
const int64_t n = gguf_get_n_tensors(meta);
constexpr const char * old_suffix = ".attn_g.weight";
constexpr const char * new_suffix = ".attn_gate.weight";
for (int64_t i = 0; i < n; ++i) {
const std::string name(gguf_get_tensor_name(meta, i));
const size_t pos = name.rfind(old_suffix);
if (pos != std::string::npos && pos + std::strlen(old_suffix) == name.size()) {
renames.emplace_back(name, name.substr(0, pos) + new_suffix);
}
}
for (const auto & [from, to] : renames) {
rename_tensor(meta, ctx, from.c_str(), to.c_str());
}
}
bool get_u32_kv(const gguf_context * meta, const char * key, uint32_t & out) {
const int64_t kid = gguf_find_key(meta, key);
if (kid < 0) return false;
@@ -3221,6 +3258,7 @@ bool translate_metadata(const llama_model_loader * ml,
if (arch_name == "qwen35moe") handle_qwen35moe(ml, meta, ctx);
if (arch_name == "qwen35") handle_qwen35 (ml, meta, ctx);
if (arch_name == "qwen3next") handle_qwen3next(meta, ctx);
if (arch_name == "laguna") handle_laguna (meta, ctx);
if (arch_name == "gptoss") handle_gptoss (ml, meta, ctx, arch_name);
if (arch_name == "lfm2") handle_lfm2 (ml, meta, ctx);
if (arch_name == "olmo3") handle_olmo3 (meta, arch_name);

View File

@@ -0,0 +1,42 @@
diff --git a/src/models/laguna.cpp b/src/models/laguna.cpp
index 1d705440e..7e708b144 100644
--- a/src/models/laguna.cpp
+++ b/src/models/laguna.cpp
@@ -275,16 +275,35 @@ llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_para
if ((uint32_t)il >= hparams.n_layer_dense_lead) {
// MoE: sigmoid routing + score-correction bias + sum-norm +
// routed_scaling_factor (all handled by build_moe_ffn).
+ ggml_tensor * up_scale = nullptr;
+ float expert_weights_scale = hparams.expert_weights_scale;
+
+#if defined(GGML_USE_METAL)
+ const ggml_type down_type = model.layers[il].ffn_down_exps->type;
+ if (n_tokens >= 32 && (ggml_is_quantized(down_type) || down_type == GGML_TYPE_F16)) {
+ // At 32 prompt tokens, Metal switches MUL_MAT_ID from its
+ // range-safe matrix-vector kernel to FP16 matrix tiles.
+ // Laguna's routed SwiGLU activations can overflow those tiles.
+ // Generation uses one token and does not need this workaround.
+ constexpr float down_input_scale = 1.0f / 256.0f;
+ up_scale = ggml_fill(ctx0,
+ model.layers[il].ffn_exp_probs_b, down_input_scale);
+ expert_weights_scale /= down_input_scale;
+ }
+#endif
+
ggml_tensor * moe_out = build_moe_ffn(cur,
model.layers[il].ffn_gate_inp,
model.layers[il].ffn_up_exps,
model.layers[il].ffn_gate_exps,
model.layers[il].ffn_down_exps,
model.layers[il].ffn_exp_probs_b,
n_expert, n_expert_used,
LLM_FFN_SILU,
hparams.expert_weights_norm,
- hparams.expert_weights_scale,
+ expert_weights_scale,
(llama_expert_gating_func_type) hparams.expert_gating_func,
- il);
+ il,
+ nullptr, nullptr,
+ up_scale);
cb(moe_out, "ffn_moe_out", il);

View File

@@ -1,93 +0,0 @@
diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp
--- a/src/llama-arch.cpp
+++ b/src/llama-arch.cpp
@@ -136,2 +136,3 @@ static const std::map<llm_arch, const char *> LLM_ARCH_NAMES = {
{ LLM_ARCH_MELLUM, "mellum" },
+ { LLM_ARCH_LAGUNA, "laguna" },
{ LLM_ARCH_UNKNOWN, "(unknown)" },
@@ -398,2 +399,3 @@ static const std::map<llm_tensor, const char *> LLM_TENSOR_NAMES = {
{ LLM_TENSOR_ATTN_GATE, "blk.%d.attn_gate" },
+ { LLM_TENSOR_ATTN_GATE_LAGUNA, "blk.%d.attn_g" },
{ LLM_TENSOR_FFN_POST_NORM, "blk.%d.post_ffw_norm" },
@@ -596,2 +598,3 @@ static const std::map<llm_tensor, llm_tensor_info> LLM_TENSOR_INFOS = {
{LLM_TENSOR_ATTN_GATE, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
+ {LLM_TENSOR_ATTN_GATE_LAGUNA, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
{LLM_TENSOR_FFN_GATE, {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL_MAT}},
diff --git a/src/llama-arch.h b/src/llama-arch.h
--- a/src/llama-arch.h
+++ b/src/llama-arch.h
@@ -145,2 +145,3 @@ enum llm_arch {
LLM_ARCH_DFLASH,
+ LLM_ARCH_LAGUNA,
LLM_ARCH_UNKNOWN,
@@ -382,2 +383,3 @@ enum llm_tensor {
LLM_TENSOR_ATTN_GATE,
+ LLM_TENSOR_ATTN_GATE_LAGUNA,
LLM_TENSOR_FFN_GATE_INP,
diff --git a/src/llama-model.cpp b/src/llama-model.cpp
--- a/src/llama-model.cpp
+++ b/src/llama-model.cpp
@@ -48,4 +48,6 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params
case LLM_ARCH_TALKIE:
return new llama_model_talkie(params);
+ case LLM_ARCH_LAGUNA:
+ return new llama_model_laguna(params);
case LLM_ARCH_DECI:
return new llama_model_deci(params);
@@ -2525,2 +2527,3 @@ llama_rope_type llama_model_rope_type(const llama_model * model) {
case LLM_ARCH_MELLUM:
+ case LLM_ARCH_LAGUNA:
case LLM_ARCH_DFLASH:
diff --git a/src/llama-model-loader.cpp b/src/llama-model-loader.cpp
--- a/src/llama-model-loader.cpp
+++ b/src/llama-model-loader.cpp
@@ -505,2 +505,3 @@ namespace GGUFMeta {
template bool llama_model_loader::get_key_or_arr<std::array<uint32_t, 512>>(enum llm_kv kid, std::array<uint32_t, 512> & result, uint32_t n, bool required);
+ template bool llama_model_loader::get_key_or_arr<uint32_t, 512>(const std::string & key, std::array<uint32_t, 512> & result, uint32_t n, bool required);
template bool llama_model_loader::get_key_or_arr<std::array<float, 512>>(enum llm_kv kid, std::array<float, 512> & result, uint32_t n, bool required);
diff --git a/src/llama-vocab.cpp b/src/llama-vocab.cpp
--- a/src/llama-vocab.cpp
+++ b/src/llama-vocab.cpp
@@ -359,2 +359,8 @@ struct llm_tokenizer_bpe : llm_tokenizer {
break;
+ case LLAMA_VOCAB_PRE_TYPE_LAGUNA:
+ regex_exprs = {
+ "(?:\\r?\\n)+(?!\\r?\\n)",
+ "(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])|[^\\r\\n\\p{L}\\p{N}]?\\p{L}+|\\p{N}| ?[^\\s\\p{L}\\p{N}]+[\\r\\n]*|\\s*[\\r\\n]+|\\s+(?!\\S)|\\s+",
+ };
+ break;
case LLAMA_VOCAB_PRE_TYPE_GPT2:
@@ -2100,2 +2106,4 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
ignore_merges = true;
+ } else if (tokenizer_pre == "laguna") {
+ pre_type = LLAMA_VOCAB_PRE_TYPE_LAGUNA;
} else if (
@@ -2773,2 +2781,3 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
|| t.first == "<end▁of▁sentence>" // deepseek-ocr
+ || t.first == "</assistant>" // poolside Laguna (eos_token_ids)
) {
diff --git a/src/llama-vocab.h b/src/llama-vocab.h
--- a/src/llama-vocab.h
+++ b/src/llama-vocab.h
@@ -65,2 +65,3 @@ enum llama_vocab_pre_type {
LLAMA_VOCAB_PRE_TYPE_MELLUM2 = 55,
+ LLAMA_VOCAB_PRE_TYPE_LAGUNA = 56,
};
diff --git a/src/models/models.h b/src/models/models.h
--- a/src/models/models.h
+++ b/src/models/models.h
@@ -1657,1 +1657,14 @@ struct llama_model_arcee : public llama_model_base {
+struct llama_model_laguna : public llama_model_base {
+ llama_model_laguna(const struct llama_model_params & params) : llama_model_base(params) {}
+ void load_arch_hparams(llama_model_loader & ml) override;
+ void load_arch_tensors(llama_model_loader & ml) override;
+
+ struct graph : public llm_graph_context {
+ graph(const llama_model & model, const llm_graph_params & params);
+ };
+
+ std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
+};
+
+
struct llama_model_arcee : public llama_model_base {

View File

@@ -1,253 +0,0 @@
#include "models/models.h"
void llama_model_laguna::load_arch_hparams(llama_model_loader & ml) {
ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps);
// MoE
ml.get_key(LLM_KV_LEADING_DENSE_BLOCK_COUNT, hparams.n_layer_dense_lead, false);
ml.get_key(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp);
ml.get_key(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_shexp, false);
ml.get_key(LLM_KV_EXPERT_SHARED_COUNT, hparams.n_expert_shared, false);
ml.get_key(LLM_KV_EXPERT_WEIGHTS_SCALE, hparams.expert_weights_scale, false);
ml.get_key(LLM_KV_EXPERT_WEIGHTS_NORM, hparams.expert_weights_norm, false);
ml.get_key(LLM_KV_EXPERT_GATING_FUNC, hparams.expert_gating_func, false);
ml.get_key(LLM_KV_ATTENTION_SLIDING_WINDOW, hparams.n_swa, false);
hparams.swa_type = LLAMA_SWA_TYPE_STANDARD;
ml.get_key_or_arr("laguna.attention.layer_types", hparams.is_swa_impl, hparams.n_layer(), false);
ml.get_key("laguna.rope.swa.dimension_count", hparams.n_rot_swa, false);
ml.get_key("laguna.rope.swa.freq_base", hparams.rope_freq_base_train_swa, false);
ml.get_key("laguna.rope.scaling.beta_fast", hparams.yarn_beta_fast, false);
ml.get_key("laguna.rope.scaling.beta_slow", hparams.yarn_beta_slow, false);
type = LLM_TYPE_UNKNOWN;
}
void llama_model_laguna::load_arch_tensors(llama_model_loader &) {
LLAMA_LOAD_LOCALS;
const int64_t n_ff_exp = hparams.n_ff_exp;
const int64_t n_ff_shexp = hparams.n_ff_shexp;
tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
if (output == NULL) {
output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED);
}
for (int i = 0; i < n_layer; ++i) {
auto & layer = layers[i];
const int64_t n_head_i = hparams.n_head(i);
const int64_t n_head_kv_i = hparams.n_head_kv(i);
const int64_t n_embd_q = n_embd_head_k * n_head_i;
const int64_t n_embd_kv = n_embd_head_k * n_head_kv_i;
layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
layer.wq = create_tensor(tn(LLM_TENSOR_ATTN_Q, "weight", i), {n_embd, n_embd_q}, 0);
layer.wk = create_tensor(tn(LLM_TENSOR_ATTN_K, "weight", i), {n_embd, n_embd_kv}, 0);
layer.wv = create_tensor(tn(LLM_TENSOR_ATTN_V, "weight", i), {n_embd, n_embd_kv}, 0);
layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_q, n_embd}, 0);
layer.wqkv_gate = create_tensor(tn(LLM_TENSOR_ATTN_GATE_LAGUNA, "weight", i), {n_embd, n_head_i}, 0);
layer.attn_q_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_NORM, "weight", i), {n_embd_head_k}, 0);
layer.attn_k_norm = create_tensor(tn(LLM_TENSOR_ATTN_K_NORM, "weight", i), {n_embd_head_k}, 0);
layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
if (i < (int) hparams.n_layer_dense_lead) {
layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0);
layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0);
} else {
layer.ffn_gate_inp = create_tensor(tn(LLM_TENSOR_FFN_GATE_INP, "weight", i), {n_embd, n_expert}, 0);
layer.ffn_exp_probs_b = create_tensor(tn(LLM_TENSOR_FFN_EXP_PROBS_B, "bias", i), {n_expert}, TENSOR_NOT_REQUIRED);
layer.ffn_gate_exps = create_tensor(tn(LLM_TENSOR_FFN_GATE_EXPS, "weight", i), { n_embd, n_ff_exp, n_expert}, 0);
layer.ffn_down_exps = create_tensor(tn(LLM_TENSOR_FFN_DOWN_EXPS, "weight", i), {n_ff_exp, n_embd, n_expert}, 0);
layer.ffn_up_exps = create_tensor(tn(LLM_TENSOR_FFN_UP_EXPS, "weight", i), { n_embd, n_ff_exp, n_expert}, 0);
layer.ffn_gate_shexp = create_tensor(tn(LLM_TENSOR_FFN_GATE_SHEXP, "weight", i), {n_embd, n_ff_shexp}, 0);
layer.ffn_down_shexp = create_tensor(tn(LLM_TENSOR_FFN_DOWN_SHEXP, "weight", i), {n_ff_shexp, n_embd}, 0);
layer.ffn_up_shexp = create_tensor(tn(LLM_TENSOR_FFN_UP_SHEXP, "weight", i), {n_embd, n_ff_shexp}, 0);
}
}
}
std::unique_ptr<llm_graph_context> llama_model_laguna::build_arch_graph(const llm_graph_params & params) const {
return std::make_unique<graph>(*this, params);
}
llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_params & params) :
llm_graph_context(params) {
const int64_t n_embd_head = hparams.n_embd_head_v();
GGML_ASSERT(n_embd_head == hparams.n_embd_head_k());
const float kq_scale = 1.0f / sqrtf(float(n_embd_head));
ggml_tensor * cur;
ggml_tensor * inpL;
inpL = build_inp_embd(model.tok_embd);
ggml_tensor * inp_pos = build_inp_pos();
auto * inp_attn = build_attn_inp_kv_iswa();
ggml_tensor * inp_out_ids = build_inp_out_ids();
for (int il = 0; il < n_layer; ++il) {
ggml_tensor * inpSA = inpL;
const int64_t n_head_il = hparams.n_head(il);
const int64_t n_head_kv_il = hparams.n_head_kv(il);
const bool is_swa = hparams.is_swa(il);
const int rope_n_dims = hparams.n_rot(il);
const float rope_base = is_swa ? hparams.rope_freq_base_train_swa : hparams.rope_freq_base_train;
const float rope_scale = is_swa ? hparams.rope_freq_scale_train_swa : hparams.rope_freq_scale_train;
const float rope_ext = is_swa ? 0.0f : 1.0f;
const float rope_bfast = hparams.yarn_beta_fast;
const float rope_bslow = hparams.yarn_beta_slow;
cur = build_norm(inpL, model.layers[il].attn_norm, NULL, LLM_NORM_RMS, il);
cb(cur, "attn_norm", il);
// self-attention
{
ggml_tensor * Qcur = build_lora_mm(model.layers[il].wq, cur);
cb(Qcur, "Qcur", il);
ggml_tensor * Kcur = build_lora_mm(model.layers[il].wk, cur);
cb(Kcur, "Kcur", il);
ggml_tensor * Vcur = build_lora_mm(model.layers[il].wv, cur);
cb(Vcur, "Vcur", il);
ggml_tensor * gate = build_lora_mm(model.layers[il].wqkv_gate, cur);
cb(gate, "gate", il);
Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head_il, n_tokens);
Kcur = ggml_reshape_3d(ctx0, Kcur, n_embd_head, n_head_kv_il, n_tokens);
Vcur = ggml_reshape_3d(ctx0, Vcur, n_embd_head, n_head_kv_il, n_tokens);
Qcur = build_norm(Qcur, model.layers[il].attn_q_norm, NULL, LLM_NORM_RMS, il);
cb(Qcur, "Qcur_normed", il);
Kcur = build_norm(Kcur, model.layers[il].attn_k_norm, NULL, LLM_NORM_RMS, il);
cb(Kcur, "Kcur_normed", il);
Qcur = ggml_rope_ext(ctx0, Qcur, inp_pos, nullptr,
rope_n_dims, rope_type, hparams.n_ctx_orig_yarn, rope_base, rope_scale,
rope_ext, hparams.rope_attn_factor, rope_bfast, rope_bslow);
Kcur = ggml_rope_ext(ctx0, Kcur, inp_pos, nullptr,
rope_n_dims, rope_type, hparams.n_ctx_orig_yarn, rope_base, rope_scale,
rope_ext, hparams.rope_attn_factor, rope_bfast, rope_bslow);
cb(Qcur, "Qcur", il);
cb(Kcur, "Kcur", il);
cb(Vcur, "Vcur", il);
cur = build_attn(inp_attn,
nullptr, nullptr, nullptr,
Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, kq_scale, il);
cb(cur, "attn_pregate", il);
gate = ggml_softplus(ctx0, gate);
cur = ggml_reshape_3d(ctx0, cur, n_embd_head, n_head_il, n_tokens);
gate = ggml_reshape_3d(ctx0, gate, 1, n_head_il, n_tokens);
cur = ggml_mul(ctx0, cur, gate);
cur = ggml_reshape_2d(ctx0, cur, n_embd_head * n_head_il, n_tokens);
cb(cur, "attn_gated", il);
cur = build_lora_mm(model.layers[il].wo, cur, model.layers[il].wo_s);
cb(cur, "attn_out", il);
}
if (il == n_layer - 1 && inp_out_ids) {
cur = ggml_get_rows(ctx0, cur, inp_out_ids);
inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids);
}
ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA);
cb(ffn_inp, "ffn_inp", il);
// feed-forward
cur = build_norm(ffn_inp, model.layers[il].ffn_norm, NULL, LLM_NORM_RMS, il);
cb(cur, "ffn_norm", il);
if ((uint32_t) il < hparams.n_layer_dense_lead) {
cur = build_ffn(cur,
model.layers[il].ffn_up, NULL, NULL,
model.layers[il].ffn_gate, NULL, NULL,
model.layers[il].ffn_down, NULL, NULL,
NULL, LLM_FFN_SILU, LLM_FFN_PAR, il);
cb(cur, "ffn_out", il);
} else {
ggml_tensor * up_scale = nullptr;
float expert_weights_scale = hparams.expert_weights_scale;
#if defined(GGML_USE_METAL)
if (n_tokens >= 32 && ggml_is_quantized(model.layers[il].ffn_down_exps->type)) {
// ggml-metal switches MUL_MAT_ID from its range-safe
// matrix-vector kernel to FP16 matrix tiles at 32 tokens
// (ne21_mm_id_min in ggml_metal_op_mul_mat_id). Laguna's routed
// SwiGLU activations can overflow those tiles. Scale the linear
// up branch and fold the inverse power-of-two factor into the
// existing routing-weight scale. Revisit this guard if the
// Metal dispatch threshold changes.
constexpr float down_input_scale = 1.0f / 256.0f;
up_scale = ggml_fill(ctx0,
model.layers[il].ffn_exp_probs_b, down_input_scale);
expert_weights_scale /= down_input_scale;
}
#endif
ggml_tensor * moe_out = build_moe_ffn(cur,
model.layers[il].ffn_gate_inp,
model.layers[il].ffn_up_exps,
model.layers[il].ffn_gate_exps,
model.layers[il].ffn_down_exps,
model.layers[il].ffn_exp_probs_b,
n_expert, n_expert_used,
LLM_FFN_SILU, hparams.expert_weights_norm,
expert_weights_scale,
(llama_expert_gating_func_type) hparams.expert_gating_func,
il,
nullptr, nullptr,
up_scale);
cb(moe_out, "ffn_moe_out", il);
ggml_tensor * ffn_shexp = build_ffn(cur,
model.layers[il].ffn_up_shexp, NULL, NULL,
model.layers[il].ffn_gate_shexp, NULL, NULL,
model.layers[il].ffn_down_shexp, NULL, NULL,
NULL, LLM_FFN_SILU, LLM_FFN_PAR, il);
cb(ffn_shexp, "ffn_shexp", il);
cur = ggml_add(ctx0, moe_out, ffn_shexp);
cb(cur, "ffn_out", il);
}
cur = ggml_add(ctx0, cur, ffn_inp);
cur = build_cvec(cur, il);
cb(cur, "l_out", il);
inpL = cur;
}
cur = inpL;
cur = build_norm(cur, model.output_norm, NULL, LLM_NORM_RMS, -1);
cb(cur, "result_norm", -1);
res->t_embd = cur;
cur = build_lora_mm(model.output, cur, model.output_s);
cb(cur, "result_output", -1);
res->t_logits = cur;
ggml_build_forward_expand(gf, cur);
}

View File

@@ -96,6 +96,14 @@ func (p *GLM46Parser) Add(s string, done bool) (content string, thinking string,
p.buffer.WriteString(s)
events := p.parseEvents()
if done && (p.state == glm46ParserState_ToolStartedEatingWhitespace || p.state == glm46ParserState_CollectingToolContent) {
event, err := p.finalizeToolCall()
if err != nil {
return "", "", nil, fmt.Errorf("incomplete GLM tool call: %v", err)
}
events = append(events, event)
}
var toolCalls []api.ToolCall
var contentSb strings.Builder
var thinkingSb strings.Builder
@@ -123,6 +131,66 @@ func (p *GLM46Parser) Add(s string, done bool) (content string, thinking string,
return contentSb.String(), thinkingSb.String(), toolCalls, nil
}
func (p *GLM46Parser) finalizeToolCall() (glm46EventRawToolCall, error) {
raw := p.buffer.String()
if overlapLen := overlap(raw, glm46ToolCloseTag); overlapLen > 0 {
raw = strings.TrimRightFunc(raw[:len(raw)-overlapLen], unicode.IsSpace)
}
escaped := escapeGLM46Content(raw)
var parsed GLMToolCallXML
if err := xml.Unmarshal([]byte("<tool_call>"+escaped+"</tool_call>"), &parsed); err != nil {
return glm46EventRawToolCall{}, err
}
if err := validateFinalGLM46ToolCall(parsed, p.tools); err != nil {
return glm46EventRawToolCall{}, err
}
p.buffer.Reset()
p.state = glm46ParserState_CollectingContent
return glm46EventRawToolCall{raw: raw}, nil
}
// validateFinalGLM46ToolCall is intentionally stricter than normal GLM parsing.
// At end-of-stream only the outer closing tag may be missing; repairing a
// truncated argument could turn partial model output into a mutating tool call.
func validateFinalGLM46ToolCall(parsed GLMToolCallXML, tools []api.Tool) error {
functionName := strings.TrimSpace(parsed.Content)
if functionName == "" {
return fmt.Errorf("empty function name")
}
if len(parsed.Keys) != len(parsed.Values) {
return fmt.Errorf("mismatched arg_key and arg_value counts: %d keys, %d values", len(parsed.Keys), len(parsed.Values))
}
var declaredTool *api.Tool
for i := range tools {
if tools[i].Function.Name == functionName {
declaredTool = &tools[i]
break
}
}
if declaredTool == nil {
return fmt.Errorf("tool %q is not declared", functionName)
}
seen := make(map[string]struct{}, len(parsed.Keys))
for _, rawKey := range parsed.Keys {
key := strings.TrimSpace(rawKey)
if key == "" {
return fmt.Errorf("empty argument name")
}
seen[key] = struct{}{}
}
for _, required := range declaredTool.Function.Parameters.Required {
if _, ok := seen[required]; !ok {
return fmt.Errorf("required argument %q is missing for tool %q", required, functionName)
}
}
return nil
}
func (p *GLM46Parser) parseEvents() []glm46Event {
var all []glm46Event

View File

@@ -3,6 +3,7 @@ package parsers
import (
"encoding/xml"
"reflect"
"strings"
"testing"
"github.com/ollama/ollama/api"
@@ -445,6 +446,140 @@ func TestGLM46ParserStreaming(t *testing.T) {
}
}
func TestGLM46ParserFinalizesCompleteToolCallOnDone(t *testing.T) {
type chunk struct {
content string
done bool
}
toolBody := `grep
<arg_key>pattern</arg_key>
<arg_value>needle</arg_value>
<arg_key>path</arg_key>
<arg_value>.</arg_value>`
tests := []struct {
name string
chunks []chunk
}{
{
name: "empty final chunk",
chunks: []chunk{
{content: "<tool_call>" + toolBody},
{done: true},
},
},
{
name: "tool body in final chunk",
chunks: []chunk{
{content: "<tool_call>" + toolBody, done: true},
},
},
{
name: "partial outer close in final chunk",
chunks: []chunk{
{content: "<tool_call>" + toolBody + "</tool_", done: true},
},
},
}
tools := glm46FinalizationTestTools()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := GLM46Parser{}
parser.Init(tools, nil, nil)
var calls []api.ToolCall
for _, chunk := range tt.chunks {
content, thinking, got, err := parser.Add(chunk.content, chunk.done)
if err != nil {
t.Fatal(err)
}
if content != "" || thinking != "" {
t.Fatalf("content=%q thinking=%q, want empty", content, thinking)
}
calls = append(calls, got...)
}
if len(calls) != 1 {
t.Fatalf("got %d tool calls, want 1", len(calls))
}
if calls[0].Function.Name != "grep" {
t.Fatalf("tool name=%q, want grep", calls[0].Function.Name)
}
if pattern, ok := calls[0].Function.Arguments.Get("pattern"); !ok || pattern != "needle" {
t.Fatalf("pattern=%#v, %v; want needle", pattern, ok)
}
if path, ok := calls[0].Function.Arguments.Get("path"); !ok || path != "." {
t.Fatalf("path=%#v, %v; want .", path, ok)
}
})
}
}
func TestGLM46ParserRejectsIncompleteToolCallOnDone(t *testing.T) {
tests := []struct {
name string
input string
}{
{name: "empty body", input: "<tool_call>"},
{name: "partial tool name", input: "<tool_call>gr"},
{
name: "incomplete argument value",
input: `<tool_call>write
<arg_key>path</arg_key>
<arg_value>out.txt</arg_value>
<arg_key>content</arg_key>
<arg_value>partial`,
},
{
name: "undeclared tool",
input: `<tool_call>shell
<arg_key>command</arg_key>
<arg_value>echo unsafe</arg_value>`,
},
{
name: "missing required argument",
input: `<tool_call>write
<arg_key>path</arg_key>
<arg_value>out.txt</arg_value>`,
},
}
tools := glm46FinalizationTestTools()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := GLM46Parser{}
parser.Init(tools, nil, nil)
content, thinking, calls, err := parser.Add(tt.input, true)
if err == nil || !strings.Contains(err.Error(), "incomplete GLM tool call") {
t.Fatalf("error=%v, want incomplete GLM tool call", err)
}
if content != "" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%v, want no output", content, thinking, calls)
}
})
}
}
func glm46FinalizationTestTools() []api.Tool {
grep := tool("grep", map[string]api.ToolProperty{
"pattern": {Type: api.PropertyType{"string"}},
"path": {Type: api.PropertyType{"string"}},
})
grep.Function.Parameters.Required = []string{"pattern", "path"}
write := tool("write", map[string]api.ToolProperty{
"path": {Type: api.PropertyType{"string"}},
"content": {Type: api.PropertyType{"string"}},
})
write.Function.Parameters.Required = []string{"path", "content"}
return []api.Tool{grep, write}
}
// TestGLMToolCallXMLOrderPreservation verifies that xml.Unmarshal preserves
// document order when collecting multiple elements with the same tag name into slices.
// This is a critical assumption for the GLM-4.6 parser's struct-based approach.

View File

@@ -535,12 +535,12 @@ func TestLagunaParserNonAssistantLastMessageStillPrimesThinking(t *testing.T) {
func TestLagunaV8ParserAssistantHistoryStillPrimesThinking(t *testing.T) {
// Laguna v8 closes assistant history and emits a fresh generation prompt,
// so an assistant tail message must not switch the parser into prefill mode.
parser := ParserForName("laguna-v8")
parser := ParserForName("poolside-v1")
if parser == nil {
t.Fatal("expected laguna-v8 parser")
t.Fatal("expected poolside-v1 parser")
}
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
t.Fatal("laguna-v8 parser should advertise tools and thinking")
t.Fatal("poolside-v1 parser should advertise tools and thinking")
}
parser.Init(nil, &api.Message{Role: "assistant", Content: "Previous."}, &api.ThinkValue{Value: true})

View File

@@ -94,7 +94,7 @@ func ParserForName(name string) Parser {
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
case "laguna-v8":
case "poolside-v1":
return &LagunaV8Parser{}
case "cohere":
return &CohereParser{}

View File

@@ -109,7 +109,7 @@ func rendererForName(name string) Renderer {
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
case "laguna":
return &LagunaRenderer{}
case "laguna-v8":
case "poolside-v1":
return &LagunaV8Renderer{}
case "cohere":
return &CohereRenderer{}

View File

@@ -69,7 +69,7 @@ func TestLeadingBOSForRenderer(t *testing.T) {
{name: "lfm2", want: "<|startoftext|>"},
{name: "lfm2-thinking", want: "<|startoftext|>"},
{name: "laguna", want: "〈|EOS|〉"},
{name: "laguna-v8", want: "〈|EOS|〉"},
{name: "poolside-v1", want: "〈|EOS|〉"},
{name: "deepseek3.1", want: "<begin▁of▁sentence>"},
{name: "cogito", want: "<begin▁of▁sentence>"},
{name: "qwen3-coder", want: ""},