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