diff --git a/docs/internal/sources.md b/docs/internal/sources.md index 0e8f060..b684c4d 100644 --- a/docs/internal/sources.md +++ b/docs/internal/sources.md @@ -85,6 +85,8 @@ returning and retains no source capability for public inspection. Prompt materialization shares a lazy metadata snapshot across root and collection resources during construction, avoiding a full metadata copy per collection. +Input metadata, message content and templates, and output contracts are captured +separately before the definition, inspection, and hash are assembled. Normalization and defaults are recorded at individual resolved field paths, including message roles and cache controls. List indexes identify fields for @@ -146,8 +148,8 @@ change rendering behavior. ConfigKit resolves profile inheritance before the handoff. Within a collection, `extends: {id: parent}` names a sibling local ID; it is not a cross-collection reference. The configuration adapter validates final effective resource references and -field values, then builds target -snapshots with backend defaults and presence/suppression state. A profile can +field values, then captures backend definitions and profile target snapshots +with backend defaults and presence/suppression state. A profile can remain partial until runtime overrides complete it. `InspectProfile` selects that captured target without prompt, artifact, schema, credential lookup, or provider work. Preparation applies runtime overrides and then checks target diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 007d66d..ee0c807 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -650,6 +650,11 @@ on Go 1.27.3; the extracted checks preserve their existing paths and rules. input metadata, inspection, and execution-selection tests keep their behavior. Update focused source/internal descriptions without duplicating format policy. +Completion: prompt inputs, messages, and output contracts are captured separately +before final definition assembly, and backend/profile construction is isolated +with presence handling intact. Configuration, prompt, engine, and focused public +tests pass on Go 1.27.3; the internal source description is updated. + ## Stage 25: Replace Parallel Attempt Returns With A Result Value **Catalog:** 25, attempt boundary. **Files:** generation coordinator and tests. diff --git a/internal/configuration/prompts.go b/internal/configuration/prompts.go index f709522..3a2932c 100644 --- a/internal/configuration/prompts.go +++ b/internal/configuration/prompts.go @@ -72,103 +72,21 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk if raw.SessionID != nil { definition.SessionID = *raw.SessionID } - seenInputs := make(map[string]bool, len(raw.Inputs)) - definition.Inputs = make([]domain.PromptInput, 0, len(raw.Inputs)) - entry := &promptEntry{ - messages: make([]*template.Template, 0, len(raw.Messages)), - requirementInputs: make([]prompt.InputMetadata, 0, len(raw.Inputs)), + inputs, inputMetadata, err := state.capturePromptInputs(id, raw.Inputs, base) + if err != nil { + return nil, err } - for index, input := range raw.Inputs { - path := base.Key("inputs").Index(index) - if input.Name == "" { - return nil, fmt.Errorf("%s: input name is required", path.Key("name")) - } - if seenInputs[input.Name] { - return nil, fmt.Errorf("%s: input name is duplicated", path.Key("name")) - } - seenInputs[input.Name] = true - contentType := "" - if input.ContentType != nil { - contentType = *input.ContentType - } - description := "" - if input.Description != nil { - description = *input.Description - } - definition.Inputs = append(definition.Inputs, domain.PromptInput{ - Name: input.Name, Required: input.Required, ContentType: contentType, Description: description, - }) - presence := state.promptMetadata[id][index] - metadata := prompt.InputMetadata{Name: input.Name, Required: input.Required} - if presence.contentType == fieldValue { - metadata.ContentType = stringPointer(contentType) - } - if presence.description == fieldValue { - metadata.Description = stringPointer(description) - } - entry.requirementInputs = append(entry.requirementInputs, metadata) + messages, compiledMessages, err := state.capturePromptMessages(raw.Messages, base, lookupOrigin) + if err != nil { + return nil, err } - definition.Templates = make([]domain.PromptMessageTemplate, 0, len(raw.Messages)) - for index, message := range raw.Messages { - path := base.Key("messages").Index(index) - if message.Role == nil { - return nil, fmt.Errorf("%s: role is required", path.Key("role")) - } - if _, err := domain.NormalizeMessageRole(*message.Role); err != nil { - return nil, fmt.Errorf("%s: %w", path.Key("role"), err) - } - if (message.Content != nil) == (message.ContentFile != nil) { - return nil, fmt.Errorf("%s: exactly one of content or content_file is required", path) - } - content := "" - if message.Content != nil { - content = *message.Content - } else { - var err error - content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin) - if err != nil { - return nil, err - } - } - var control *domain.CacheControl - if message.CacheControl != nil { - if message.CacheControl.Type == nil { - return nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type")) - } - control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)} - if message.CacheControl.TTL != nil { - control.TTL = *message.CacheControl.TTL - } - if _, err := domain.NormalizeCacheControl(control); err != nil { - return nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err) - } - } - compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content) - if err != nil { - return nil, fmt.Errorf("%s: invalid template syntax: %w", path, err) - } - entry.messages = append(entry.messages, compiled) - definition.Templates = append(definition.Templates, domain.PromptMessageTemplate{ - Role: *message.Role, Content: content, CacheControl: control, - }) - } - if raw.Output == nil { - return nil, fmt.Errorf("%s: output is required", base.Key("output")) - } - if raw.Output.Format == nil || raw.Output.ValidationMode == nil { - return nil, fmt.Errorf("%s: format and validation_mode are required", base.Key("output")) - } - contract := domain.OutputContract{ - Format: domain.OutputFormat(*raw.Output.Format), - ValidationMode: domain.ValidationMode(*raw.Output.ValidationMode), - RepairAttempts: raw.Output.RepairAttempts, - } - if raw.Output.SchemaPath != nil { - contract.SchemaPath = *raw.Output.SchemaPath - } - if err := domain.ValidateOutputContract(contract); err != nil { - return nil, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err) + contract, err := capturePromptOutput(raw.Output, base) + if err != nil { + return nil, err } + definition.Inputs = inputs + definition.Templates = messages + entry := &promptEntry{requirementInputs: inputMetadata, messages: compiledMessages} definition.OutputFormat = contract.Format definition.Validation = contract if raw.SessionID != nil { @@ -192,6 +110,113 @@ func (state *loaded) materializePrompt(id string, raw promptConfig, base configk return entry, nil } +func (state *loaded) capturePromptInputs(id string, rawInputs []promptInputConfig, base configkit.Path) ([]domain.PromptInput, []prompt.InputMetadata, error) { + seenInputs := make(map[string]bool, len(rawInputs)) + inputs := make([]domain.PromptInput, 0, len(rawInputs)) + metadata := make([]prompt.InputMetadata, 0, len(rawInputs)) + for index, input := range rawInputs { + path := base.Key("inputs").Index(index) + if input.Name == "" { + return nil, nil, fmt.Errorf("%s: input name is required", path.Key("name")) + } + if seenInputs[input.Name] { + return nil, nil, fmt.Errorf("%s: input name is duplicated", path.Key("name")) + } + seenInputs[input.Name] = true + contentType := "" + if input.ContentType != nil { + contentType = *input.ContentType + } + description := "" + if input.Description != nil { + description = *input.Description + } + inputs = append(inputs, domain.PromptInput{ + Name: input.Name, Required: input.Required, ContentType: contentType, Description: description, + }) + presence := state.promptMetadata[id][index] + item := prompt.InputMetadata{Name: input.Name, Required: input.Required} + if presence.contentType == fieldValue { + item.ContentType = stringPointer(contentType) + } + if presence.description == fieldValue { + item.Description = stringPointer(description) + } + metadata = append(metadata, item) + } + return inputs, metadata, nil +} + +func (state *loaded) capturePromptMessages(rawMessages []promptMessageConfig, base configkit.Path, lookupOrigin func(configkit.Path) (configkit.FieldInfo, bool)) ([]domain.PromptMessageTemplate, []*template.Template, error) { + templates := make([]domain.PromptMessageTemplate, 0, len(rawMessages)) + compiledMessages := make([]*template.Template, 0, len(rawMessages)) + for index, message := range rawMessages { + path := base.Key("messages").Index(index) + if message.Role == nil { + return nil, nil, fmt.Errorf("%s: role is required", path.Key("role")) + } + if _, err := domain.NormalizeMessageRole(*message.Role); err != nil { + return nil, nil, fmt.Errorf("%s: %w", path.Key("role"), err) + } + if (message.Content != nil) == (message.ContentFile != nil) { + return nil, nil, fmt.Errorf("%s: exactly one of content or content_file is required", path) + } + content := "" + if message.Content != nil { + content = *message.Content + } else { + var err error + content, err = state.readContent(path.Key("content_file"), *message.ContentFile, lookupOrigin) + if err != nil { + return nil, nil, err + } + } + var control *domain.CacheControl + if message.CacheControl != nil { + if message.CacheControl.Type == nil { + return nil, nil, fmt.Errorf("%s: type is required", path.Key("cache_control").Key("type")) + } + control = &domain.CacheControl{Type: domain.CacheControlType(*message.CacheControl.Type)} + if message.CacheControl.TTL != nil { + control.TTL = *message.CacheControl.TTL + } + if _, err := domain.NormalizeCacheControl(control); err != nil { + return nil, nil, fmt.Errorf("%s: %w", path.Key("cache_control"), err) + } + } + compiled, err := prompt.CompileTemplate(fmt.Sprintf("msg_%d", index), content) + if err != nil { + return nil, nil, fmt.Errorf("%s: invalid template syntax: %w", path, err) + } + compiledMessages = append(compiledMessages, compiled) + templates = append(templates, domain.PromptMessageTemplate{ + Role: *message.Role, Content: content, CacheControl: control, + }) + } + return templates, compiledMessages, nil +} + +func capturePromptOutput(output *promptOutputConfig, base configkit.Path) (domain.OutputContract, error) { + if output == nil { + return domain.OutputContract{}, fmt.Errorf("%s: output is required", base.Key("output")) + } + if output.Format == nil || output.ValidationMode == nil { + return domain.OutputContract{}, fmt.Errorf("%s: format and validation_mode are required", base.Key("output")) + } + contract := domain.OutputContract{ + Format: domain.OutputFormat(*output.Format), + ValidationMode: domain.ValidationMode(*output.ValidationMode), + RepairAttempts: output.RepairAttempts, + } + if output.SchemaPath != nil { + contract.SchemaPath = *output.SchemaPath + } + if err := domain.ValidateOutputContract(contract); err != nil { + return domain.OutputContract{}, fmt.Errorf("%s: invalid output contract: %w", base.Key("output"), err) + } + return contract, nil +} + func stringPointer(value string) *string { copy := value return © diff --git a/internal/configuration/targets.go b/internal/configuration/targets.go index 7072a7b..4b6c361 100644 --- a/internal/configuration/targets.go +++ b/internal/configuration/targets.go @@ -17,34 +17,7 @@ func (state *loaded) buildExecutionProfiles() error { definitions := make(map[string]domain.Backend, len(backendConfigs)) for _, id := range sortedKeys(backendConfigs) { config := backendConfigs[id] - definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams} - if config.SmokeTests != nil { - smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile} - if config.SmokeTests.Reachability != nil { - smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint} - } - if config.SmokeTests.Authentication != nil { - smokeTests.Authentication = &domain.AuthenticationProbeConfig{ - Kind: config.SmokeTests.Authentication.Kind, - Endpoint: config.SmokeTests.Authentication.Endpoint, - } - } - definition.SmokeTests = smokeTests - } - if config.Endpoint != nil { - definition.Endpoint = *config.Endpoint - } - if config.APIKeyEnv != nil { - definition.APIKeyEnv = *config.APIKeyEnv - } - if config.ConcurrencyLimit != nil { - definition.ConcurrencyLimit = *config.ConcurrencyLimit - } - if config.QueueCapacity != nil { - definition.QueueCapacity = *config.QueueCapacity - definition.QueueCapacitySet = definition.ConcurrencyLimit > 0 - } - definitions[id] = definition + definitions[id] = captureBackend(id, config) } registry, err := backend.NewEffectiveRegistry(definitions) if err != nil { @@ -54,72 +27,110 @@ func (state *loaded) buildExecutionProfiles() error { state.indexedProfiles = make(map[string]domain.ExecutionProfile, len(profileConfigs)) for _, id := range sortedKeys(profileConfigs) { config := profileConfigs[id] - fields := state.profiles[id] - target := defaults.ExecutionTargetDefault() - if config.Backend != nil && *config.Backend != "" { - selected, err := registry.GetBackend(*config.Backend) - if err != nil { - return fmt.Errorf("profile %q backend: %w", id, err) - } - target.BackendID = selected.ID - target.Endpoint = selected.Endpoint - target.APIKeyEnv = selected.APIKeyEnv - target.ExtraParams = selected.ExtraParams + profile, err := captureExecutionProfile(id, config, state.profiles[id], registry) + if err != nil { + return err } - presence := domain.ExecutionTargetPresence{} - if fields["endpoint"] != fieldAbsent { - target.Endpoint = valueOrZero(config.Endpoint) - } - if fields["model"] != fieldAbsent { - target.Model = valueOrZero(config.Model) - } - if fields["temperature"] != fieldAbsent { - target.Temperature = valueOrZero(config.Temperature) - presence.Temperature = fields["temperature"] == fieldValue - } - if fields["top_p"] != fieldAbsent { - target.TopP = valueOrZero(config.TopP) - presence.TopP = fields["top_p"] == fieldValue - } - if fields["max_tokens"] != fieldAbsent { - target.MaxTokens = valueOrZero(config.MaxTokens) - presence.MaxTokens = fields["max_tokens"] == fieldValue - } - if fields["timeout_seconds"] == fieldValue { - target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds) - presence.TimeoutSeconds = true - } - if fields["service_tier"] != fieldAbsent { - target.ServiceTier = valueOrZero(config.ServiceTier) - } - if fields["reasoning_effort"] != fieldAbsent { - target.ReasoningEffort = valueOrZero(config.ReasoningEffort) - } - if fields["api_key_env"] != fieldAbsent { - target.APIKeyEnv = valueOrZero(config.APIKeyEnv) - } - if fields["api_key_required"] != fieldAbsent { - target.APIKeyRequired = valueOrZero(config.APIKeyRequired) - } - if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent { - target.APIKeyEnv = "" - } - switch fields["extra_params"] { - case fieldNull: - target.ExtraParams = nil - case fieldValue: - merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams) - if err != nil { - return fmt.Errorf("profile %q extra parameters: %w", id, err) - } - target.ExtraParams = merged - } - profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence} state.indexedProfiles[id] = profile } return nil } +func captureBackend(id string, config backendConfig) domain.Backend { + definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams} + if config.SmokeTests != nil { + smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile} + if config.SmokeTests.Reachability != nil { + smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint} + } + if config.SmokeTests.Authentication != nil { + smokeTests.Authentication = &domain.AuthenticationProbeConfig{ + Kind: config.SmokeTests.Authentication.Kind, + Endpoint: config.SmokeTests.Authentication.Endpoint, + } + } + definition.SmokeTests = smokeTests + } + if config.Endpoint != nil { + definition.Endpoint = *config.Endpoint + } + if config.APIKeyEnv != nil { + definition.APIKeyEnv = *config.APIKeyEnv + } + if config.ConcurrencyLimit != nil { + definition.ConcurrencyLimit = *config.ConcurrencyLimit + } + if config.QueueCapacity != nil { + definition.QueueCapacity = *config.QueueCapacity + definition.QueueCapacitySet = definition.ConcurrencyLimit > 0 + } + return definition +} + +func captureExecutionProfile(id string, config profileConfig, fields map[string]fieldState, registry *backend.Registry) (domain.ExecutionProfile, error) { + target := defaults.ExecutionTargetDefault() + if config.Backend != nil && *config.Backend != "" { + selected, err := registry.GetBackend(*config.Backend) + if err != nil { + return domain.ExecutionProfile{}, fmt.Errorf("profile %q backend: %w", id, err) + } + target.BackendID = selected.ID + target.Endpoint = selected.Endpoint + target.APIKeyEnv = selected.APIKeyEnv + target.ExtraParams = selected.ExtraParams + } + presence := domain.ExecutionTargetPresence{} + if fields["endpoint"] != fieldAbsent { + target.Endpoint = valueOrZero(config.Endpoint) + } + if fields["model"] != fieldAbsent { + target.Model = valueOrZero(config.Model) + } + if fields["temperature"] != fieldAbsent { + target.Temperature = valueOrZero(config.Temperature) + presence.Temperature = fields["temperature"] == fieldValue + } + if fields["top_p"] != fieldAbsent { + target.TopP = valueOrZero(config.TopP) + presence.TopP = fields["top_p"] == fieldValue + } + if fields["max_tokens"] != fieldAbsent { + target.MaxTokens = valueOrZero(config.MaxTokens) + presence.MaxTokens = fields["max_tokens"] == fieldValue + } + if fields["timeout_seconds"] == fieldValue { + target.TimeoutSeconds = valueOrZero(config.TimeoutSeconds) + presence.TimeoutSeconds = true + } + if fields["service_tier"] != fieldAbsent { + target.ServiceTier = valueOrZero(config.ServiceTier) + } + if fields["reasoning_effort"] != fieldAbsent { + target.ReasoningEffort = valueOrZero(config.ReasoningEffort) + } + if fields["api_key_env"] != fieldAbsent { + target.APIKeyEnv = valueOrZero(config.APIKeyEnv) + } + if fields["api_key_required"] != fieldAbsent { + target.APIKeyRequired = valueOrZero(config.APIKeyRequired) + } + if target.APIKeyRequired && fields["api_key_env"] == fieldAbsent { + target.APIKeyEnv = "" + } + switch fields["extra_params"] { + case fieldNull: + target.ExtraParams = nil + case fieldValue: + merged, err := overlayProviderMap(target.ExtraParams, config.ExtraParams) + if err != nil { + return domain.ExecutionProfile{}, fmt.Errorf("profile %q extra parameters: %w", id, err) + } + target.ExtraParams = merged + } + profile := domain.ExecutionProfile{ID: id, ResolvedTarget: &target, TargetPresence: presence} + return profile, nil +} + func valueOrZero[T any](value *T) T { if value == nil { var zero T