package config // internal_call.go: the ONE derivation of a config.Stage for a call the ENGINE makes on its own behalf — // a bank role (terminologist / type-classifier), a repair span, tomorrow's annotator or Ф2 judge. // // Why it exists (D39.87 §2): those calls used to be hand-written `config.Stage{Name: …, Role: …, Model: …}` // literals — three of them in shipping code plus one in the live rig — and every literal silently dropped // every OTHER field the owner had configured. That is how the reasoning knob went missing from the bank // roles: nobody "forgot a key", the shape of the code loses fields by construction. Adding the key to three // literals would reproduce the defect at the fourth site, so the literals are gone instead: this is the only // place in the tree that builds a Stage outside the loader, and TestSyntheticStageSeamIsSingle keeps it that // way. // ValidReasoningEffort reports whether v is the engine's NEUTRAL effort vocabulary. ONE definition, shared // by the stage key and by every gate that configures a call class, so the two can never drift into accepting // different words for the same knob. // // ⚠ It is deliberately provider-BLIND, and that is a known limit rather than an oversight: the capability // layer maps these words per provider, and a per-model enum does not exist in models.yaml. So `medium` on a // DeepSeek-shaped model is undefined vendor behaviour, and the vendor's own `xhigh`/`max` are unreachable // from our config. Validating against the RESOLVED model's capability would need a new per-model table — a // new mechanism, not a use of an existing one — so it stays a named finding, not a silent gap. func ValidReasoningEffort(v string) bool { switch v { case "", "off", "low", "medium", "high": return true } return false } // InternalCall is everything an engine-internal call says about itself. Every OTHER Stage field is decided // ONCE in Stage() below, with the reason written down — so a new call site inherits the decisions instead of // re-taking them by accident, and a new Stage FIELD is a compile-visible decision rather than a silent zero // (TestInternalCallDecidesEveryStageField). type InternalCall struct { // Name is the stage name the call is accounted under. It is a request-hash axis and the log axis, so it // must be the name the operator sees in `tmctl report` for this call class. Name string // Role is the engine role — the COST axis (RoleSpentUSD, the per-role sub-budgets) and the log axis. Role string // Model is the RESOLVED model this call goes to (gates carry their own `model:`, already validated). Model string // Reasoning is the effort the owner configured for this call CLASS, in the engine's neutral vocabulary // ("" | off | low | medium | high). "" means "leave the provider's own default", which is not the same // as off — on a capability whose control is extra_body_disable it is the provider's default of THINKING // OFF (llm.Capability.applyToBody), which is why the value has to travel rather than be assumed. Reasoning string } // Stage renders the internal call as the config.Stage the runner's money path takes. It is deliberately // TOTAL over Stage's fields: what is set is listed here, and what stays zero stays zero for a written reason. func (c InternalCall) Stage() Stage { return Stage{ Name: c.Name, Role: c.Role, Model: c.Model, Reasoning: c.Reasoning, // ResolvedModel mirrors Model: an internal call is NOT label-routed (label routing resolves the // book's `stages:` at load), so the configured model IS the resolved one. It is set rather than left // zero because the load-time exposure walk reads ResolvedModel to name a stage×model pair. ResolvedModel: c.Model, // --- deliberately zero --------------------------------------------------------------------- // Temperature: 0, and NOT inherited from a parent prose stage. These calls return STRUCTURE that // the engine re-parses — a term table, a type verdict, a span replacement — where sampling variance // is pure risk; the owner's stage temperature is a STYLE choice about prose and means nothing here. // (It is also a request-hash field, so this is the value every existing bank checkpoint was bought // at; changing it would re-buy them, which is a reason to be sure, not the reason to choose.) // // ReasoningMaxTokens: 0 — it reserves the ADDITIVE reasoning buffer (D6.2/D13.6), and the gates that // make internal calls carry no such key. That is not an oversight left open here: the loader REFUSES // an additive-billing provider for gates.terminology.model / gates.repair.model precisely because // the block cannot reserve the buffer, so a call reaching this seam is never on an additive provider. // A future gate that wants an additive model must add the key AND lift that refusal together. // // PromptOverride / PromptPath / PromptVersion: internal calls arrive with their messages ALREADY // rendered (each gate resolves and versions its own prompt), so the stage template machinery is not // consulted for them. // // EscalateTo / ResolvedHop: no escalation hop. maybeEscalate is reached from runStage, not from the // internal-call paths, and these calls degrade by leaving their work unchanged rather than by paying // a second model. // // LabelModels: see ResolvedModel — no label routing. A model that may not receive the book's content // labels is refused loudly by clientFor before any money moves, so this is fail-closed, not silent. // // FewShot: nil — few-shot is a prose-translation device; a term table has its own prompt. // // LegacyPrompt / LegacyPrompts / LegacyChannel: retired keys, declared only to be REJECTED at load. } }