返回 DeepSeek-Reasonix
builder.go
根目录 / internal / extension / builder.go
1 package extension
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/provider"
10 "reasonix/internal/tool"
11 )
12
13 // Activator binds live resources (MCP sidecars, watchers) to a freshly
14 // frozen snapshot. Stage 2 wires none; the seam exists so later stages plug
15 // in without touching the build pipeline. A nil *RuntimeSet result is
16 // treated as an empty set bound to the snapshot's generation.
17 type Activator func(ctx context.Context, snap *RuntimeSnapshot) (*RuntimeSet, error)
18
19 // Builder assembles a RuntimeSnapshot from registered contributors through a
20 // fixed pipeline: Discover → Parse → Validate → Resolve → Assemble → Freeze →
21 // Activate. The pipeline is linear by design — every contributor sees the
22 // same rules, and every consumer reads the same frozen result.
23 type Builder struct {
24 contributors []Contributor
25 generation uint64
26 systemPrompt string
27 activator Activator
28 conflictPolicy ConflictPolicy
29 }
30
31 // NewBuilder returns an empty builder with generation 0.
32 func NewBuilder() *Builder { return &Builder{} }
33
34 // AddContributor registers contributors. Registration order never influences
35 // the snapshot — the catalog, winner rules, and interceptor chains all sort
36 // on contribution data, not arrival order — so callers may register in any
37 // order.
38 func (b *Builder) AddContributor(contributors ...Contributor) *Builder {
39 b.contributors = append(b.contributors, contributors...)
40 return b
41 }
42
43 // WithGeneration sets the snapshot generation. Generations pair with
44 // RuntimeSet.CloseIfGeneration to keep stale cleanup from closing a newer
45 // runtime's resources.
46 func (b *Builder) WithGeneration(gen uint64) *Builder {
47 b.generation = gen
48 return b
49 }
50
51 // WithSystemPrompt sets the assembled system prompt text. It participates in
52 // CacheHash because it is part of the provider-visible request prefix.
53 func (b *Builder) WithSystemPrompt(prompt string) *Builder {
54 b.systemPrompt = prompt
55 return b
56 }
57
58 // WithActivator installs the activation seam. The default activator returns
59 // an empty RuntimeSet bound to the snapshot generation.
60 func (b *Builder) WithActivator(a Activator) *Builder {
61 b.activator = a
62 return b
63 }
64
65 // ConflictPolicy selects how resolution treats same-tier duplicates of one
66 // canonical ID claimed by distinct sources for a shadowed kind.
67 type ConflictPolicy int
68
69 const (
70 // ConflictFail is the default: a disputed ID aborts the build with a
71 // ConflictError. v1 extensions use it — a package must never silently
72 // override another package's capability.
73 ConflictFail ConflictPolicy = iota
74 // ConflictCollect keeps the deterministic winner (highest tier, then
75 // first registration) and records the dispute on the snapshot's
76 // Diagnostics instead of failing the build. Boot's legacy assembly uses
77 // it: those resources already resolved their clashes inside their own
78 // discovery passes, and surfacing a residual dispute must never change
79 // whether a session boots. Malformed contributions still fail validation,
80 // and replacement-slot disputes still fail resolution — only shadowing
81 // conflicts are collected.
82 ConflictCollect
83 )
84
85 // WithConflictPolicy sets how same-tier multi-source duplicates are treated.
86 // The zero value is ConflictFail; see ConflictPolicy.
87 func (b *Builder) WithConflictPolicy(p ConflictPolicy) *Builder {
88 b.conflictPolicy = p
89 return b
90 }
91
92 // ValidationError reports one malformed contribution. Build collects all of
93 // them so a broken manifest surfaces every problem in one pass.
94 type ValidationError struct {
95 Kind ContributionKind
96 ID string
97 Reason string
98 }
99
100 func (e *ValidationError) Error() string {
101 if e.ID == "" {
102 return fmt.Sprintf("extension: invalid %s contribution: %s", e.Kind, e.Reason)
103 }
104 return fmt.Sprintf("extension: invalid %s %q: %s", e.Kind, e.ID, e.Reason)
105 }
106
107 // Build runs the full pipeline and returns the frozen snapshot plus its bound
108 // runtime resources. Any validation, conflict, or activation error aborts the
109 // build: publishing half-resolved state would let a losing contribution leak
110 // into the runtime. Under ConflictCollect a shadowing conflict no longer
111 // aborts: the deterministic winner is kept and the dispute is recorded on the
112 // snapshot's Diagnostics.
113 func (b *Builder) Build(ctx context.Context) (*RuntimeSnapshot, *RuntimeSet, error) {
114 raw, err := b.discover(ctx)
115 if err != nil {
116 return nil, nil, err
117 }
118 parsed := parseContributions(raw)
119 if err := validateContributions(parsed); err != nil {
120 return nil, nil, err
121 }
122 resolved, replacements, conflicts, err := resolveContributions(parsed, b.conflictPolicy)
123 if err != nil {
124 return nil, nil, err
125 }
126 snap := b.assemble(resolved, replacements, conflicts)
127 runtimeSet, err := b.activate(ctx, snap)
128 if err != nil {
129 return nil, nil, err
130 }
131 return snap, runtimeSet, nil
132 }
133
134 // discover asks every contributor for its offerings and stamps the
135 // per-contributor registration sequence. An empty Origin defaults to the
136 // contributor name so conflict reports always have something meaningful to
137 // say.
138 func (b *Builder) discover(ctx context.Context) ([]Contribution, error) {
139 var out []Contribution
140 for _, c := range b.contributors {
141 contribs, err := c.Contribute(ctx)
142 if err != nil {
143 return nil, fmt.Errorf("extension: contributor %q: %w", c.Name(), err)
144 }
145 for i, ct := range contribs {
146 ct.Order = i
147 if ct.Source.Origin == "" {
148 ct.Source.Origin = c.Name()
149 }
150 out = append(out, ct)
151 }
152 }
153 return out, nil
154 }
155
156 // parseContributions normalizes raw contributions. Stage 2 has no manifest
157 // decoding to do — adapters hand over typed payloads — so parsing is limited
158 // to ID hygiene; the stage exists so later manifest formats slot into the
159 // pipeline without reordering it.
160 func parseContributions(in []Contribution) []Contribution {
161 out := make([]Contribution, len(in))
162 for i, ct := range in {
163 ct.ID = strings.TrimSpace(ct.ID)
164 out[i] = ct
165 }
166 return out
167 }
168
169 // validateContributions rejects malformed contributions before any winner
170 // rules run, so resolution never has to guess what an invalid entry meant.
171 func validateContributions(cs []Contribution) error {
172 var errs []error
173 for _, ct := range cs {
174 if !knownKind(ct.Kind) {
175 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "unknown kind"})
176 continue
177 }
178 if ct.ID == "" {
179 errs = append(errs, &ValidationError{Kind: ct.Kind, Reason: "empty ID"})
180 continue
181 }
182 if strings.ContainsAny(ct.ID, " \t\n") {
183 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "ID contains whitespace"})
184 }
185 if !knownScope(ct.Source.Scope) {
186 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: fmt.Sprintf("unknown scope %q", ct.Source.Scope)})
187 }
188 switch ct.Kind {
189 case KindTool:
190 errs = append(errs, validateToolContribution(ct)...)
191 case KindProvider:
192 if _, _, ok := splitProviderRef(ct.ID); !ok {
193 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "provider ID must be a <name>/<model> ref"})
194 }
195 case KindInterceptor:
196 if !knownInterceptorPoint(InterceptorPoint(ct.ID)) {
197 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "unknown interceptor point"})
198 }
199 if err := ValidatePriority(ct.Priority); err != nil {
200 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: err.Error()})
201 }
202 }
203 }
204 return errors.Join(errs...)
205 }
206
207 // validateToolContribution enforces the tool ID contract: lowercase names,
208 // the mcp__<server>__<tool> namespace for MCP-backed tools, and a payload the
209 // assembler can render into a provider schema.
210 func validateToolContribution(ct Contribution) []error {
211 var errs []error
212 if ct.ID != strings.ToLower(ct.ID) {
213 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "tool IDs must be lowercase"})
214 }
215 if strings.HasPrefix(ct.ID, tool.MCPNamePrefix) {
216 if _, _, ok := tool.SplitMCPName(ct.ID); !ok {
217 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "malformed MCP tool name, want mcp__<server>__<tool>"})
218 }
219 } else if _, isMCP := ct.Payload.(tool.MCPMetadata); isMCP {
220 // An MCP-backed tool outside the mcp__ namespace would collide with
221 // built-in names and bypass MCP-specific policy checks.
222 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "MCP tool IDs must start with mcp__"})
223 }
224 if _, ok := toolSchemaOf(ct); !ok {
225 errs = append(errs, &ValidationError{Kind: ct.Kind, ID: ct.ID, Reason: "payload must be tool.Tool, tool.ContractEntry, or provider.ToolSchema"})
226 }
227 return errs
228 }
229
230 // ValidToolID reports whether id satisfies the kernel's tool-ID contract:
231 // lowercase, and a well-formed mcp__<server>__<tool> name when the MCP
232 // namespace prefix is present. It encodes the ID-shape half of
233 // validateToolContribution (keep the two in sync); assemblers wrapping a
234 // pre-kernel legacy registry use it to skip names that predate the contract
235 // instead of failing the whole build.
236 func ValidToolID(id string) bool {
237 if id != strings.ToLower(id) {
238 return false
239 }
240 if strings.HasPrefix(id, tool.MCPNamePrefix) {
241 _, _, ok := tool.SplitMCPName(id)
242 return ok
243 }
244 return true
245 }
246
247 // toolSchemaOf renders a tool contribution's payload into a provider schema.
248 // Parameters are canonicalized here — once — so every consumer, including
249 // CacheHash, sees identical bytes regardless of how the contributor marshaled
250 // them.
251 func toolSchemaOf(ct Contribution) (provider.ToolSchema, bool) {
252 switch p := ct.Payload.(type) {
253 case tool.Tool:
254 return provider.ToolSchema{
255 Name: p.Name(),
256 Description: p.Description(),
257 Parameters: provider.CanonicalizeSchema(p.Schema()),
258 }, true
259 case tool.ContractEntry:
260 return provider.ToolSchema{
261 Name: p.Name,
262 Description: p.Description,
263 Parameters: provider.CanonicalizeSchema(p.Schema),
264 }, true
265 case provider.ToolSchema:
266 p.Parameters = provider.CanonicalizeSchema(p.Parameters)
267 return p, true
268 default:
269 return provider.ToolSchema{}, false
270 }
271 }
272
273 // resolveContributions applies the winner rules and returns the effective
274 // contribution set, the replacement-slot owners, and — under ConflictCollect —
275 // the shadowing disputes it resolved without failing.
276 //
277 // Shadowed kinds (tools, skills, commands, MCP servers, providers, prompts,
278 // themes, UI actions, strategies): the highest-tier contribution wins the
279 // canonical ID; distinct sources tied at that tier are a hard ConflictError
280 // under ConflictFail — the kernel refuses to pick a winner the user didn't
281 // ask for — or a recorded ConflictError under ConflictCollect, with the same
282 // deterministic winner kept. Duplicates from a single source collapse to the
283 // first registration, mirroring the first-root-wins behavior inside today's
284 // discovery passes.
285 //
286 // Hooks and interceptors are additive: every contribution survives and
287 // nothing ever conflicts.
288 //
289 // Replacement claims come from payloads implementing SlotClaimer; a second
290 // claimant for a slot is a hard SlotConflictError under both policies — a
291 // slot replaces runtime behavior outright, so there is no shadowing winner
292 // to keep.
293 func resolveContributions(cs []Contribution, policy ConflictPolicy) (resolved []Contribution, replacements map[Slot]ContributionSource, conflicts []ConflictError, err error) {
294 type key struct {
295 kind ContributionKind
296 id string
297 }
298 groups := map[key][]Contribution{}
299 var order []key
300 for _, ct := range cs {
301 k := key{ct.Kind, ct.ID}
302 if _, seen := groups[k]; !seen {
303 order = append(order, k)
304 }
305 groups[k] = append(groups[k], ct)
306 }
307
308 var errs []error
309 resolved = make([]Contribution, 0, len(cs))
310 claims := NewReplaceClaims()
311 for _, k := range order {
312 group := groups[k]
313 if additiveKind(k.kind) {
314 resolved = append(resolved, group...)
315 } else {
316 winner, sources, conflicted := resolveGroup(group)
317 if conflicted {
318 conflict := ConflictError{Kind: k.kind, ID: k.id, Sources: sources}
319 if policy == ConflictCollect {
320 // The dispute is surfaced on the snapshot; the winner
321 // rules above still decide what the runtime sees.
322 conflicts = append(conflicts, conflict)
323 resolved = append(resolved, winner)
324 continue
325 }
326 errs = append(errs, &conflict)
327 continue
328 }
329 resolved = append(resolved, winner)
330 }
331 }
332 // Claims are collected across all contributions, not just winners: a
333 // losing contribution must not silently keep a slot it declared, because
334 // slots replace runtime behavior regardless of catalog shadowing.
335 for _, ct := range cs {
336 claimer, ok := ct.Payload.(SlotClaimer)
337 if !ok {
338 continue
339 }
340 for _, slot := range claimer.ReplacementSlots() {
341 if err := claims.Claim(slot, ct.Source); err != nil {
342 errs = append(errs, err)
343 }
344 }
345 }
346 if err := errors.Join(errs...); err != nil {
347 return nil, nil, nil, err
348 }
349 return resolved, claims.Claims(), conflicts, nil
350 }
351
352 // resolveGroup picks the winning contribution for one (kind, id): the first
353 // registration among the highest-tier entries, plus whether the top tier is
354 // disputed between distinct sources. The winner is returned even when the
355 // group is disputed so a ConflictCollect build keeps resolving to the same
356 // deterministic entry; ConflictFail callers discard it.
357 func resolveGroup(group []Contribution) (winner Contribution, sources []ContributionSource, conflicted bool) {
358 best := -1
359 for _, ct := range group {
360 if r := tierRank(ct.Source.Scope); r > best {
361 best = r
362 }
363 }
364 var top []Contribution
365 for _, ct := range group {
366 if tierRank(ct.Source.Scope) == best {
367 top = append(top, ct)
368 }
369 }
370 winner = top[0]
371 for _, ct := range top[1:] {
372 if ct.Order < winner.Order {
373 winner = ct
374 }
375 }
376 if sources, ok := conflictingSources(group); ok {
377 return winner, sources, true
378 }
379 return winner, nil, false
380 }
381
382 // assemble freezes the resolved set into an immutable snapshot. Everything
383 // derivable is derived here — schemas rendered and sorted, chains grouped and
384 // ordered, hashes computed — so snapshot accessors stay trivial copies.
385 // conflicts are the shadowing disputes a ConflictCollect build resolved with
386 // its ordinary winner rules; they are frozen onto the snapshot as Diagnostics
387 // in pipeline (first-appearance) order.
388 func (b *Builder) assemble(resolved []Contribution, replacements map[Slot]ContributionSource, conflicts []ConflictError) *RuntimeSnapshot {
389 catalog := NewCatalog()
390 catalog.Add(resolved...)
391
392 schemas := make([]provider.ToolSchema, 0)
393 for _, ct := range catalog.ByKind(KindTool) {
394 schema, ok := toolSchemaOf(ct)
395 if ok {
396 schemas = append(schemas, schema)
397 }
398 }
399 schemas = normalizeToolSchemas(schemas)
400
401 chains := map[InterceptorPoint][]Contribution{}
402 for _, ct := range catalog.ByKind(KindInterceptor) {
403 point := InterceptorPoint(ct.ID)
404 chains[point] = append(chains[point], ct)
405 }
406 for point, chain := range chains {
407 chains[point] = SortInterceptors(chain)
408 }
409
410 repl := make(map[Slot]ContributionSource, len(replacements))
411 for slot, owner := range replacements {
412 repl[slot] = owner
413 }
414
415 diagnostics := make([]string, 0, len(conflicts))
416 for i := range conflicts {
417 diagnostics = append(diagnostics, conflicts[i].Error())
418 }
419
420 systemHash, toolsHash, cacheHash := computeCacheShape(b.systemPrompt, schemas)
421
422 catalog.freeze()
423 return &RuntimeSnapshot{
424 generation: b.generation,
425 catalog: catalog,
426 systemPrompt: b.systemPrompt,
427 toolSchemas: schemas,
428 interceptorChain: chains,
429 replacements: repl,
430 diagnostics: diagnostics,
431 cacheHash: cacheHash,
432 systemHash: systemHash,
433 toolsHash: toolsHash,
434 }
435 }
436
437 // activate binds runtime resources through the configured Activator, or
438 // returns an empty set. The snapshot is already frozen at this point: an
439 // activator must observe, never mutate.
440 func (b *Builder) activate(ctx context.Context, snap *RuntimeSnapshot) (*RuntimeSet, error) {
441 if b.activator == nil {
442 return NewRuntimeSet(snap.Generation()), nil
443 }
444 runtimeSet, err := b.activator(ctx, snap)
445 if err != nil {
446 return nil, fmt.Errorf("extension: activate generation %d: %w", snap.Generation(), err)
447 }
448 if runtimeSet == nil {
449 runtimeSet = NewRuntimeSet(snap.Generation())
450 }
451 return runtimeSet, nil
452 }
453
453 lines GO