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