From d4f9bd5fe546b0c5f321a23293a0028df7808f93 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Mon, 29 Dec 2025 00:12:24 -0500 Subject: [PATCH] types: add Kind field to model.Name for 5-part naming Extends the model name structure from 4-part to 5-part: host/namespace/kind/model:tag The Kind field is optional and supports: - "skill" for skill packages - "agent" for agent packages (future) - empty for regular models Parser detects valid kinds to distinguish between old format (host/namespace/model) and new format (host/namespace/kind/model). --- types/model/name.go | 129 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 100 insertions(+), 29 deletions(-) diff --git a/types/model/name.go b/types/model/name.go index a46f3e28d..25c29bde3 100644 --- a/types/model/name.go +++ b/types/model/name.go @@ -59,6 +59,7 @@ type partKind int const ( kindHost partKind = iota kindNamespace + kindKind kindModel kindTag kindDigest @@ -70,6 +71,8 @@ func (k partKind) String() string { return "host" case kindNamespace: return "namespace" + case kindKind: + return "kind" case kindModel: return "model" case kindTag: @@ -89,6 +92,7 @@ func (k partKind) String() string { type Name struct { Host string Namespace string + Kind string // Optional: "skill", "agent", or empty for models Model string Tag string } @@ -97,34 +101,27 @@ type Name struct { // format of a valid name string is: // // s: -// { host } "/" { namespace } "/" { model } ":" { tag } "@" { digest } +// { host } "/" { namespace } "/" { kind } "/" { model } ":" { tag } // { host } "/" { namespace } "/" { model } ":" { tag } -// { host } "/" { namespace } "/" { model } "@" { digest } -// { host } "/" { namespace } "/" { model } -// { namespace } "/" { model } ":" { tag } "@" { digest } +// { namespace } "/" { kind } "/" { model } ":" { tag } // { namespace } "/" { model } ":" { tag } -// { namespace } "/" { model } "@" { digest } -// { namespace } "/" { model } -// { model } ":" { tag } "@" { digest } // { model } ":" { tag } -// { model } "@" { digest } // { model } -// "@" { digest } // host: // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." | ":" }* // length: [1, 350] // namespace: // pattern: { alphanum | "_" } { alphanum | "-" | "_" }* // length: [1, 80] +// kind: +// pattern: "skill" | "agent" | "" (empty for models) +// length: [0, 80] // model: // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }* // length: [1, 80] // tag: // pattern: { alphanum | "_" } { alphanum | "-" | "_" | "." }* // length: [1, 80] -// digest: -// pattern: { alphanum | "_" } { alphanum | "-" | ":" }* -// length: [1, 80] // // Most users should use [ParseName] instead, unless need to support // different defaults than DefaultName. @@ -136,6 +133,12 @@ func ParseName(s string) Name { return Merge(ParseNameBare(s), DefaultName()) } +// ValidKinds are the allowed values for the Kind field +var ValidKinds = map[string]bool{ + "skill": true, + "agent": true, +} + // ParseNameBare parses s as a name string and returns a Name. No merge with // [DefaultName] is performed. func ParseNameBare(s string) Name { @@ -153,6 +156,30 @@ func ParseNameBare(s string) Name { return n } + s, n.Kind, promised = cutPromised(s, "/") + if !promised { + // Only 2 parts: namespace/model - what we parsed as Kind is actually Namespace + n.Namespace = n.Kind + n.Kind = "" + return n + } + + // Check if what we parsed as Kind is actually a valid kind value + if !ValidKinds[n.Kind] { + // Not a valid kind - this is the old 3-part format: host/namespace/model + // Shift: Kind -> Namespace, s -> Host + n.Namespace = n.Kind + n.Kind = "" + + scheme, host, ok := strings.Cut(s, "://") + if !ok { + host = scheme + } + n.Host = host + return n + } + + // Valid kind found - continue parsing for namespace and optional host s, n.Namespace, promised = cutPromised(s, "/") if !promised { n.Namespace = s @@ -168,20 +195,32 @@ func ParseNameBare(s string) Name { return n } -// ParseNameFromFilepath parses a 4-part filepath as a Name. The parts are +// ParseNameFromFilepath parses a 4 or 5-part filepath as a Name. The parts are // expected to be in the form: // // { host } "/" { namespace } "/" { model } "/" { tag } +// { host } "/" { namespace } "/" { kind } "/" { model } "/" { tag } func ParseNameFromFilepath(s string) (n Name) { parts := strings.Split(s, string(filepath.Separator)) - if len(parts) != 4 { + + switch len(parts) { + case 4: + // Old format: host/namespace/model/tag + n.Host = parts[0] + n.Namespace = parts[1] + n.Model = parts[2] + n.Tag = parts[3] + case 5: + // New format: host/namespace/kind/model/tag + n.Host = parts[0] + n.Namespace = parts[1] + n.Kind = parts[2] + n.Model = parts[3] + n.Tag = parts[4] + default: return Name{} } - n.Host = parts[0] - n.Namespace = parts[1] - n.Model = parts[2] - n.Tag = parts[3] if !n.IsFullyQualified() { return Name{} } @@ -189,11 +228,12 @@ func ParseNameFromFilepath(s string) (n Name) { return n } -// Merge merges the host, namespace, and tag parts of the two names, +// Merge merges the host, namespace, kind, and tag parts of the two names, // preferring the non-empty parts of a. func Merge(a, b Name) Name { a.Host = cmp.Or(a.Host, b.Host) a.Namespace = cmp.Or(a.Namespace, b.Namespace) + a.Kind = cmp.Or(a.Kind, b.Kind) a.Tag = cmp.Or(a.Tag, b.Tag) return a } @@ -211,6 +251,10 @@ func (n Name) String() string { b.WriteString(n.Namespace) b.WriteByte('/') } + if n.Kind != "" { + b.WriteString(n.Kind) + b.WriteByte('/') + } b.WriteString(n.Model) if n.Tag != "" { b.WriteByte(':') @@ -233,6 +277,12 @@ func (n Name) DisplayShortest() string { sb.WriteByte('/') } + // include kind if present + if n.Kind != "" { + sb.WriteString(n.Kind) + sb.WriteByte('/') + } + // always include model and tag sb.WriteString(n.Model) sb.WriteString(":") @@ -256,18 +306,23 @@ func (n Name) IsValid() bool { } // IsFullyQualified returns true if all parts of the name are present and -// valid without the digest. +// valid without the digest. Kind is optional and only validated if non-empty. func (n Name) IsFullyQualified() bool { - parts := []string{ - n.Host, - n.Namespace, - n.Model, - n.Tag, + if !isValidPart(kindHost, n.Host) { + return false } - for i, part := range parts { - if !isValidPart(partKind(i), part) { - return false - } + if !isValidPart(kindNamespace, n.Namespace) { + return false + } + // Kind is optional - only validate if present + if n.Kind != "" && !isValidPart(kindKind, n.Kind) { + return false + } + if !isValidPart(kindModel, n.Model) { + return false + } + if !isValidPart(kindTag, n.Tag) { + return false } return true } @@ -276,6 +331,7 @@ func (n Name) IsFullyQualified() bool { // host to tag as a directory in the form: // // {host}/{namespace}/{model}/{tag} +// {host}/{namespace}/{kind}/{model}/{tag} // // It uses the system's filepath separator and ensures the path is clean. // @@ -285,6 +341,15 @@ func (n Name) Filepath() string { if !n.IsFullyQualified() { panic("illegal attempt to get filepath of invalid name") } + if n.Kind != "" { + return filepath.Join( + n.Host, + n.Namespace, + n.Kind, + n.Model, + n.Tag, + ) + } return filepath.Join( n.Host, n.Namespace, @@ -301,6 +366,7 @@ func (n Name) LogValue() slog.Value { func (n Name) EqualFold(o Name) bool { return strings.EqualFold(n.Host, o.Host) && strings.EqualFold(n.Namespace, o.Namespace) && + strings.EqualFold(n.Kind, o.Kind) && strings.EqualFold(n.Model, o.Model) && strings.EqualFold(n.Tag, o.Tag) } @@ -317,6 +383,11 @@ func isValidLen(kind partKind, s string) bool { } func isValidPart(kind partKind, s string) bool { + // Kind must be one of the valid values + if kind == kindKind { + return ValidKinds[s] + } + if !isValidLen(kind, s) { return false }