返回 DeepSeek-Reasonix
replace.go
根目录 / internal / extension / replace.go
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
182 lines GO