| 1 | package extension |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | ) |
| 7 | |
| 8 | // Slot names a replaceable runtime seam. Unlike additive contributions, a |
| 9 | // slot has exactly one owner at a time: two packages both replacing the |
| 10 | // system prompt would produce a hybrid neither intended, so the kernel |
| 11 | // refuses the combination instead of picking one silently. |
| 12 | type Slot string |
| 13 | |
| 14 | const ( |
| 15 | SlotSystemPrompt Slot = "system_prompt" |
| 16 | SlotContext Slot = "context" |
| 17 | SlotProviderRequest Slot = "provider_request" |
| 18 | SlotProviderResponse Slot = "provider_response" |
| 19 | SlotCompaction Slot = "compaction" |
| 20 | SlotSessionPolicy Slot = "session_policy" |
| 21 | SlotPermission Slot = "permission" |
| 22 | SlotFrontendEvents Slot = "frontend_events" |
| 23 | ) |
| 24 | |
| 25 | // namedSlots is the closed set of bare slot names ParseSlot accepts. The set |
| 26 | // is deliberately closed: a slot is a runtime seam the kernel knows how to |
| 27 | // honor, so inventing one in a manifest must fail loudly, not be ignored. |
| 28 | var namedSlots = map[Slot]bool{ |
| 29 | SlotSystemPrompt: true, |
| 30 | SlotContext: true, |
| 31 | SlotProviderRequest: true, |
| 32 | SlotProviderResponse: true, |
| 33 | SlotCompaction: true, |
| 34 | SlotSessionPolicy: true, |
| 35 | SlotPermission: true, |
| 36 | SlotFrontendEvents: true, |
| 37 | } |
| 38 | |
| 39 | const ( |
| 40 | // slotToolPrefix namespaces per-tool replacement slots. |
| 41 | slotToolPrefix = "tool:" |
| 42 | // slotProviderPrefix namespaces per-provider-ref replacement slots. |
| 43 | slotProviderPrefix = "provider:" |
| 44 | ) |
| 45 | |
| 46 | // SlotTool returns the replacement slot for one tool, e.g. "tool:bash". |
| 47 | func SlotTool(name string) Slot { return Slot(slotToolPrefix + name) } |
| 48 | |
| 49 | // SlotProviderRef returns the replacement slot for one provider ref, e.g. |
| 50 | // "provider:openai/gpt-5". |
| 51 | func SlotProviderRef(ref string) Slot { return Slot(slotProviderPrefix + ref) } |
| 52 | |
| 53 | // ParseSlot validates a slot string. Bare names must be one of the declared |
| 54 | // slots; the tool:/provider: forms must carry a well-formed target so a typo |
| 55 | // cannot create a slot nothing will ever read. |
| 56 | func ParseSlot(s string) (Slot, error) { |
| 57 | if namedSlots[Slot(s)] { |
| 58 | return Slot(s), nil |
| 59 | } |
| 60 | if rest, ok := strings.CutPrefix(s, slotToolPrefix); ok { |
| 61 | if rest == "" || strings.ContainsAny(rest, " \t\n") { |
| 62 | return "", fmt.Errorf("extension: invalid tool slot %q: empty or whitespace tool name", s) |
| 63 | } |
| 64 | return Slot(s), nil |
| 65 | } |
| 66 | if rest, ok := strings.CutPrefix(s, slotProviderPrefix); ok { |
| 67 | if !validProviderSlotTarget(rest) { |
| 68 | return "", fmt.Errorf("extension: invalid provider slot %q: want provider:<name>/<model> or provider:plugin/<plugin>/<name>/<model>", s) |
| 69 | } |
| 70 | return Slot(s), nil |
| 71 | } |
| 72 | return "", fmt.Errorf("extension: unknown slot %q", s) |
| 73 | } |
| 74 | |
| 75 | // validProviderSlotTarget reports whether ref can name a provider:<ref> |
| 76 | // replacement slot: an ordinary <name>/<model> ref, or an extension-hosted |
| 77 | // plugin/<pluginID>/<name>/<model> ref (stage 7). Plugin providers carry the |
| 78 | // extra namespace segments so claims can name them without relaxing the |
| 79 | // ordinary ref grammar. |
| 80 | func validProviderSlotTarget(ref string) bool { |
| 81 | if _, _, ok := splitProviderRef(ref); ok { |
| 82 | return true |
| 83 | } |
| 84 | rest, ok := strings.CutPrefix(ref, "plugin/") |
| 85 | if !ok { |
| 86 | return false |
| 87 | } |
| 88 | pluginID, nameModel, ok := strings.Cut(rest, "/") |
| 89 | if !ok || pluginID == "" || strings.ContainsAny(pluginID, " \t\n") { |
| 90 | return false |
| 91 | } |
| 92 | _, _, ok = splitProviderRef(nameModel) |
| 93 | return ok |
| 94 | } |
| 95 | |
| 96 | // splitProviderRef splits a "name/model" ref. Providers address models by |
| 97 | // exactly one slash (see internal/boot/resolver.go), so refs without that |
| 98 | // shape are malformed rather than merely unusual. |
| 99 | func splitProviderRef(ref string) (name, model string, ok bool) { |
| 100 | name, model, found := strings.Cut(ref, "/") |
| 101 | if !found || name == "" || model == "" || strings.Contains(model, "/") { |
| 102 | return "", "", false |
| 103 | } |
| 104 | return name, model, true |
| 105 | } |
| 106 | |
| 107 | // IsProviderRef reports whether ref is a kernel-shaped provider ID |
| 108 | // ("<name>/<model>", exactly one slash). Legacy provider catalogs can carry |
| 109 | // refs the kernel rejects at validation time — a bare provider name, or a |
| 110 | // model that itself contains a slash — so assemblers wrapping those catalogs |
| 111 | // use this to pre-filter entries instead of failing the whole build. |
| 112 | func IsProviderRef(ref string) bool { |
| 113 | _, _, ok := splitProviderRef(ref) |
| 114 | return ok |
| 115 | } |
| 116 | |
| 117 | // SlotClaimer is implemented by contribution payloads that replace a runtime |
| 118 | // seam. The kernel enforces single ownership per slot at resolve time; it |
| 119 | // does not judge whether a contributor was entitled to claim a slot — that |
| 120 | // legitimacy check (claims ⊆ what the contributor declared) belongs to the |
| 121 | // caller at contribution time, because only the caller knows the |
| 122 | // contributor's manifest. |
| 123 | type SlotClaimer interface { |
| 124 | ReplacementSlots() []Slot |
| 125 | } |
| 126 | |
| 127 | // SlotConflictError reports a second claimant for an already-owned slot. |
| 128 | type SlotConflictError struct { |
| 129 | Slot Slot |
| 130 | Owners []ContributionSource |
| 131 | } |
| 132 | |
| 133 | func (e *SlotConflictError) Error() string { |
| 134 | labels := make([]string, 0, len(e.Owners)) |
| 135 | for _, s := range e.Owners { |
| 136 | labels = append(labels, s.label()) |
| 137 | } |
| 138 | return fmt.Sprintf("extension: replacement slot %q claimed by %v", e.Slot, labels) |
| 139 | } |
| 140 | |
| 141 | // ReplaceClaims tracks slot ownership during resolution. It is per-build |
| 142 | // state: the winning owners are frozen into the snapshot's Replacements map. |
| 143 | type ReplaceClaims struct { |
| 144 | owners map[Slot]ContributionSource |
| 145 | } |
| 146 | |
| 147 | // NewReplaceClaims returns an empty claim table. |
| 148 | func NewReplaceClaims() *ReplaceClaims { |
| 149 | return &ReplaceClaims{owners: map[Slot]ContributionSource{}} |
| 150 | } |
| 151 | |
| 152 | // Claim records src as the owner of slot. The second claimant for a slot gets |
| 153 | // a *SlotConflictError naming both owners — never a silent override. Claiming |
| 154 | // an invalid slot string is an error for the same reason ParseSlot rejects |
| 155 | // it. |
| 156 | func (c *ReplaceClaims) Claim(slot Slot, src ContributionSource) error { |
| 157 | if _, err := ParseSlot(string(slot)); err != nil { |
| 158 | return err |
| 159 | } |
| 160 | if owner, taken := c.owners[slot]; taken { |
| 161 | return &SlotConflictError{Slot: slot, Owners: []ContributionSource{owner, src}} |
| 162 | } |
| 163 | c.owners[slot] = src |
| 164 | return nil |
| 165 | } |
| 166 | |
| 167 | // Owner returns the current owner of slot. |
| 168 | func (c *ReplaceClaims) Owner(slot Slot) (ContributionSource, bool) { |
| 169 | owner, ok := c.owners[slot] |
| 170 | return owner, ok |
| 171 | } |
| 172 | |
| 173 | // Claims returns a copy of the ownership table, so callers cannot mutate |
| 174 | // build state through the result. |
| 175 | func (c *ReplaceClaims) Claims() map[Slot]ContributionSource { |
| 176 | out := make(map[Slot]ContributionSource, len(c.owners)) |
| 177 | for slot, owner := range c.owners { |
| 178 | out[slot] = owner |
| 179 | } |
| 180 | return out |
| 181 | } |
| 182 |