textmachine/backend/internal/config/internal_call.go

91 lines
5.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.
}
}