From dfbee7568fe02bb4de34a64870656ea10ef28e6c Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 23:54:18 +0300 Subject: [PATCH] fix cognitive local model discovery --- adapters/models/openai_compatible_local.go | 24 ++++++ .../models/openai_compatible_local_test.go | 31 ++++++++ cmd/core-runtime/learning_test.go | 26 +++++++ dev/ACTIVE_TASK.json | 2 +- ...E-003-restore-local-medium-invocation.json | 78 +++++++++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json diff --git a/adapters/models/openai_compatible_local.go b/adapters/models/openai_compatible_local.go index 820c57a..36dcbcc 100644 --- a/adapters/models/openai_compatible_local.go +++ b/adapters/models/openai_compatible_local.go @@ -65,6 +65,9 @@ type compatibleModelsResponse struct { ID string `json:"id"` Created int64 `json:"created"` OwnedBy string `json:"owned_by"` + ModelKind string `json:"model_kind"` + Capabilities map[string]bool `json:"capabilities"` + Modalities map[string]bool `json:"modalities"` Meta struct { Context int64 `json:"n_ctx"` TrainingContext int64 `json:"n_ctx_train"` @@ -94,6 +97,12 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover if item.ID == "" { continue } + // The gateway catalogue may contain image/audio runtimes beside local + // chat models. Discovery is not allowed to qualify those as text + // advisory models merely because they appear in /v1/models. + if !isTextAdvisoryModel(item.ModelKind, item.Capabilities, item.Modalities) { + continue + } stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType) digest := sha256.Sum256([]byte(stableMetadata)) contextBytes := item.Meta.Context * 4 @@ -105,6 +114,19 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover return models, ProviderProbe{Health: capability.HealthAvailable} } +func isTextAdvisoryModel(kind string, capabilities, modalities map[string]bool) bool { + if strings.EqualFold(strings.TrimSpace(kind), "image-generation") || strings.EqualFold(strings.TrimSpace(kind), "audio-transcription") || strings.EqualFold(strings.TrimSpace(kind), "text-to-speech") { + return false + } + if chat, known := capabilities["chat"]; known && !chat { + return false + } + if text, known := modalities["text"]; known && !text { + return false + } + return true +} + type compatibleChatRequest struct { Model string `json:"model"` Messages []compatibleMessage `json:"messages"` @@ -236,6 +258,8 @@ func mapCompatibleStatus(status int) cognitive.FinishReason { return cognitive.FinishContextTooLarge case 429: return cognitive.FinishRateLimited + case 400, 422: + return cognitive.FinishInvalidProviderResponse default: return cognitive.FinishProviderUnavailable } diff --git a/adapters/models/openai_compatible_local_test.go b/adapters/models/openai_compatible_local_test.go index fceedd5..b90e6c0 100644 --- a/adapters/models/openai_compatible_local_test.go +++ b/adapters/models/openai_compatible_local_test.go @@ -29,6 +29,37 @@ func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Respon }, nil } +type mixedModelsRoundTripper struct{} + +func (mixedModelsRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"image-model","model_kind":"image-generation","capabilities":{"chat":false,"image_generation":true},"modalities":{"text":false,"image_generation":true}},{"id":"text-model","model_kind":"chat","capabilities":{"chat":true},"modalities":{"text":true},"meta":{}}]}`)), + Header: make(http.Header), + Request: request, + }, nil +} + +func TestOpenAICompatibleLocalAdapterDoesNotQualifyNonTextModels(t *testing.T) { + adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", mixedModelsRoundTripper{}) + if err != nil { + t.Fatal(err) + } + models, probe := adapter.Discover(context.Background()) + if probe.Health != capability.HealthAvailable || len(models) != 1 || models[0].ProviderModelID != "text-model" { + t.Fatalf("unexpected filtered discovery: probe=%+v models=%+v", probe, models) + } +} + +func TestOpenAICompatibleLocalAdapterMapsImmediateBadRequest(t *testing.T) { + if got := mapCompatibleStatus(http.StatusBadRequest); got != cognitive.FinishInvalidProviderResponse { + t.Fatalf("400 finish=%s, want %s", got, cognitive.FinishInvalidProviderResponse) + } + if got := mapCompatibleStatus(http.StatusUnprocessableEntity); got != cognitive.FinishInvalidProviderResponse { + t.Fatalf("422 finish=%s, want %s", got, cognitive.FinishInvalidProviderResponse) + } +} + func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) { adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{}) if err != nil { diff --git a/cmd/core-runtime/learning_test.go b/cmd/core-runtime/learning_test.go index 35ffb78..bf38c72 100644 --- a/cmd/core-runtime/learning_test.go +++ b/cmd/core-runtime/learning_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "testing" "time" @@ -38,6 +39,18 @@ type learningSelfStub struct { ledgers int } +type failingLearningSelfStub struct{} + +func (failingLearningSelfStub) SaveLearningSignal(context.Context, selfcontract.LearningSignal) error { + return errors.New("self repository unavailable") +} +func (failingLearningSelfStub) SaveImprovementNeed(context.Context, selfcontract.ImprovementNeed) error { + return errors.New("self repository unavailable") +} +func (failingLearningSelfStub) SaveLearningLedger(context.Context, selfcontract.LearningLedgerEntry) error { + return errors.New("self repository unavailable") +} + func (s *learningSelfStub) SaveLearningSignal(context.Context, selfcontract.LearningSignal) error { s.signals++ return nil @@ -104,3 +117,16 @@ func TestCorrectionWritesBoundedSelfEvidenceWithoutChangingResponsePath(t *testi t.Fatalf("completed learning path was changed") } } + +func TestSelfRepositoryFailureDoesNotDiscardUserFacingLearningPath(t *testing.T) { + epistemicsStore := &learningEpistemicsStub{} + recorder := learningRecorder{epistemics: epistemicsStore, self: failingLearningSelfStub{}} + recorder.recordCorrection(context.Background(), correctionLearningInput{ + Intent: interaction.OriginalHumanIntent{ID: "intent-failure"}, + Utterance: interaction.HumanUtterance{ID: "utterance-failure", PrincipalID: "principal-failure"}, + At: time.UnixMilli(3000), + }) + if len(epistemicsStore.experiences) != 1 { + t.Fatalf("SELF repository failure must not discard the completed epistemics response path") + } +} diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index feef746..355aafd 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-EVOLVE-004-RUNTIME-001-wire-self-learning-loop.json","task_id":"GEN2-EVOLVE-004-RUNTIME-001","task_revision":2} +{"task_file":"dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json","task_id":"GEN2-COGNITIVE-003","task_revision":3} diff --git a/dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json b/dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json new file mode 100644 index 0000000..a4bd06c --- /dev/null +++ b/dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json @@ -0,0 +1,78 @@ +{ + "task_id": "GEN2-COGNITIVE-003", + "task_revision": 3, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Restore bounded OpenAI-compatible Local MEDIUM invocation without changing canonical ownership or Core semantics.", + "requirement_ids": ["REQ-GEN2-EVOLVE-004-LOCAL-MEDIUM-INVOCATION"], + "allowed_change_scope": { + "modules": ["cognitive-worker provider invocation", "existing local OpenAI-compatible adapter", "targeted evidence"], + "paths": [ + "cmd/cognitive-worker/main.go", + "cmd/cognitive-worker/advisory.go", + "cmd/cognitive-worker/gateway_registry.go", + "cmd/cognitive-worker/*_test.go", + "adapters/models/openai_compatible_local.go", + "adapters/models/openai_compatible_local_test.go", + "cmd/core-runtime/learning_test.go", + "dev/ACTIVE_TASK.json", + "dev/tasks/GEN2-COGNITIVE-003-restore-local-medium-invocation.json", + "docs/evidence/**" + ] + }, + "forbidden_change_scope": { + "paths": [ + "cmd/core-runtime/capability_chat.go", + "cmd/core-runtime/capability_chat_test.go", + "cmd/core-runtime/chat_orchestrator.go", + "cmd/core-runtime/chat_orchestrator_test.go", + "cmd/core-runtime/cognitive_interpretation.go", + "cmd/core-runtime/cognitive_interpretation_test.go", + "cmd/core-runtime/development_gate.go", + "cmd/core-runtime/development_gate_test.go", + "cmd/core-runtime/household_scenario_test.go", + "cmd/core-runtime/human_response_gate_test.go", + "cmd/core-runtime/information_request_test.go", + "cmd/core-runtime/learning.go", + "cmd/core-runtime/main.go", + "cmd/core-runtime/main_test.go", + "cmd/core-runtime/research_client.go", + "cmd/core-runtime/research_client_test.go", + "adapters/postgres/**", + "modules/**", + "contracts/**", + "architecture/**", + "database/**", + "security/**", + "deploy/**", + "go.mod", + "go.sum", + "UI", + "V1 retirement", + "GEN2-EVOLVE-005", + "Gen3/**" + ] + }, + "allowed_architecture_delta": { + "new_cross_module_dependencies": 0, + "new_canonical_owners": 0, + "new_privileges": 0, + "new_third_party_dependencies": 0, + "new_database_owner_roles": 0 + }, + "contracts": { + "consume": ["existing cognitive advisory contract", "existing capability binding/profile metadata", "existing OpenAI-compatible local adapter"], + "may_change": ["provider request normalization and typed immediate failure mapping only"] + }, + "semantic_owner": "Cognitive gateway remains the sole owner of provider invocation and route selection.", + "canonical_state": ["discovery is not invocation qualification", "optional provider parameters are capability-gated", "immediate provider rejection is not reported as timeout"], + "state_transitions": ["binding selection -> normalized request -> provider invocation -> typed result"], + "side_effects_and_privileges": ["no secrets or authorization headers in evidence", "no direct database access", "no provider calls outside the existing gateway boundary"], + "persistence_and_migrations": ["no schema change"], + "failure_retry_restart": ["bounded invocation; no timeout inflation; no infinite retry; preserve fail-closed/fallback semantics"], + "assumptions": ["Local MEDIUM gateway remains the existing provider endpoint", "candidate slot B is available for isolated verification", "cmd/core-runtime/learning_test.go is a preserved carry-forward regression from the preceding runtime task"], + "acceptance_scenarios": ["minimal invocation completes", "ordinary and bounded-context invocation complete", "immediate provider 4xx maps to typed rejection without timeout", "qualification requires bounded invocation evidence"], + "required_tests": ["targeted go test", "targeted go vet", "preflight", "check-diff", "independent", "architecture checks", "git diff --check"], + "terminal_gap_states": ["BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "REQUIRES_MIGRATION_DECISION", "BLOCKED_BY_DEPLOYMENT_CONTROL"] +}