diff --git a/cmd/cognitive-worker/gateway.go b/cmd/cognitive-worker/gateway.go index 8f8715d..84c1412 100644 --- a/cmd/cognitive-worker/gateway.go +++ b/cmd/cognitive-worker/gateway.go @@ -135,7 +135,51 @@ func (g cognitiveGateway) advisoryHandler(w http.ResponseWriter, r *http.Request writeGatewayResponse(w, g.invokeBuiltinAdvisory(r.Context(), request, maxOutputBytes)) return } - writeGatewayResponse(w, cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage}) + writeGatewayResponse(w, gatewayAdvisoryResponse(request, response)) +} + +const iterativeRetrievalMarker = "" + +// gatewayAdvisoryResponse is the only model-facing extraction point. The +// model may express a bounded retrieval intent, but it cannot provide scope, +// identity, policy or storage details; Core supplies those on continuation. +func gatewayAdvisoryResponse(request cognitive.GatewayAdvisoryRequest, response cognitive.AdvisoryResponse) cognitive.GatewayAdvisoryResponse { + result := cognitive.GatewayAdvisoryResponse{Version: response.Version, RequestID: response.RequestID, AdvisoryText: response.AdvisoryText, ReferencedExcerptIDs: response.ReferencedExcerptIDs, FinishReason: response.FinishReason, Usage: response.Usage} + if !request.AllowIterativeRetrieval || response.FinishReason != cognitive.FinishCompleted { + return result + } + intent, text, ok := parseIterativeRetrievalIntent(response.AdvisoryText) + if !ok { + return result + } + result.AdvisoryText = text + result.IterativeRetrieval = &intent + return result +} + +func parseIterativeRetrievalIntent(text string) (cognitive.IterativeRetrievalRequest, string, bool) { + start := strings.LastIndex(text, iterativeRetrievalMarker) + if start < 0 { + return cognitive.IterativeRetrievalRequest{}, text, false + } + payload := strings.TrimSpace(text[start+len(iterativeRetrievalMarker):]) + var intent cognitive.IterativeRetrievalRequest + if err := json.Unmarshal([]byte(payload), &intent); err != nil || !validIterativeRetrievalIntent(intent) { + return cognitive.IterativeRetrievalRequest{}, text, false + } + return intent, strings.TrimSpace(text[:start]), true +} + +func validIterativeRetrievalIntent(intent cognitive.IterativeRetrievalRequest) bool { + if strings.TrimSpace(intent.Query) == "" || strings.TrimSpace(intent.Reason) == "" || len([]rune(intent.Query)) > 512 || len([]rune(intent.Reason)) > 512 { + return false + } + switch intent.Lane { + case cognitive.IterativeRetrievalCurrentFact, cognitive.IterativeRetrievalPositiveExperience, cognitive.IterativeRetrievalNegativeExperience, cognitive.IterativeRetrievalContradiction, cognitive.IterativeRetrievalHistoricalAnalogy: + return true + default: + return false + } } func (g cognitiveGateway) invokeAdmitted(ctx context.Context, leaseID string, provider advisoryProvider, invocation cognitive.AdvisoryRequest, maxOutputBytes int) (cognitive.AdvisoryResponse, error) { diff --git a/cmd/cognitive-worker/gateway_registry_test.go b/cmd/cognitive-worker/gateway_registry_test.go index a87c6f2..7238cb0 100644 --- a/cmd/cognitive-worker/gateway_registry_test.go +++ b/cmd/cognitive-worker/gateway_registry_test.go @@ -108,6 +108,20 @@ func TestActiveRegistryBindingReachesProviderAdapter(t *testing.T) { } } +func TestGatewayExtractsOnlyBoundedTypedRetrievalIntent(t *testing.T) { + request := cognitive.GatewayAdvisoryRequest{AllowIterativeRetrieval: true} + response := cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request", FinishReason: cognitive.FinishCompleted, AdvisoryText: "{\"Query\":\"sync catalog\",\"SemanticSubject\":\"catalog sync\",\"Lane\":\"NEGATIVE_EXPERIENCE\",\"Reason\":\"need known failure\"}"} + got := gatewayAdvisoryResponse(request, response) + if got.IterativeRetrieval == nil || got.IterativeRetrieval.Lane != cognitive.IterativeRetrievalNegativeExperience || got.AdvisoryText != "" { + t.Fatalf("response=%+v", got) + } + invalid := response + invalid.AdvisoryText = "{\"Query\":\"all memory\",\"Lane\":\"UNRESTRICTED\",\"Reason\":\"everything\"}" + if got := gatewayAdvisoryResponse(request, invalid); got.IterativeRetrieval != nil { + t.Fatalf("invalid intent accepted: %+v", got) + } +} + func TestStaleUnavailableBindingRevalidatesOnceBeforeInvocation(t *testing.T) { selected := gatewaySelection() selected.Binding.DataPolicy = capability.DataPolicyLocalOnly diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index a2f5e8b..c994967 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -42,6 +42,19 @@ func compileInteractionContextWithExperience(projection interactiondomain.Contex if len(projection.Fragments) == 0 && len(experiences) == 0 { return projection.Text, nil } + candidates := interactionContextCandidates(projection, focusProjectID, experiences, query) + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: candidates, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: 2048, ContextScopeRef: focusProjectID, FreshnessRequirement: "conversation-current", RiskClass: "low"}) + if err != nil { + return "", err + } + parts := make([]string, 0, len(bundle.Excerpts)) + for _, excerpt := range bundle.Excerpts { + parts = append(parts, excerpt.Text) + } + return strings.Join(parts, "\n"), nil +} + +func interactionContextCandidates(projection interactiondomain.ContextProjection, focusProjectID string, experiences []epistemics.ExperienceRecord, query string) []cognitive.ContextCandidate { candidates := make([]cognitive.ContextCandidate, 0, len(projection.Fragments)+len(experiences)) for _, fragment := range projection.Fragments { candidates = append(candidates, cognitive.ContextCandidate{ @@ -70,15 +83,7 @@ func compileInteractionContextWithExperience(projection interactiondomain.Contex for _, record := range retrieved.Contradictions { candidates = append(candidates, experienceContextCandidate(record, "contradiction", true)) } - bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: candidates, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: 2048, ContextScopeRef: focusProjectID, FreshnessRequirement: "conversation-current", RiskClass: "low"}) - if err != nil { - return "", err - } - parts := make([]string, 0, len(bundle.Excerpts)) - for _, excerpt := range bundle.Excerpts { - parts = append(parts, excerpt.Text) - } - return strings.Join(parts, "\n"), nil + return candidates } func needsHistoricalAnalogy(query string) bool { @@ -101,6 +106,69 @@ func experienceContextCandidate(record epistemics.ExperienceRecord, lane string, return cognitive.ContextCandidate{Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("experience:" + string(record.ID)), SourceKind: "epistemics-experience", SourceID: "epistemics:experience-records", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: text}, Relevance: 1, Lane: lane, ContextScopeRef: record.ContextScopeRef, FreshnessState: "CURRENT", Negative: negative} } +const ( + iterativeRetrievalMaxRounds = 1 + iterativeRetrievalMaxFragments = 2 + iterativeRetrievalMaxAddedBytes = 1024 + iterativeRetrievalMaxLatency = 1500 * time.Millisecond +) + +func newIterativeContextRetriever(history []interaction.HumanUtterance, references []interaction.ProjectReference, principalID, focusProjectID string, experiences []epistemics.ExperienceRecord) iterativeContextRetriever { + seen := map[string]struct{}{} + return func(ctx context.Context, request cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse { + started := time.Now() + if request.Pass < 0 || request.Pass >= iterativeRetrievalMaxRounds || request.RemainingBytes <= 0 || request.RemainingBytes > iterativeRetrievalMaxAddedBytes { + return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "budget"} + } + projection := interactiondomain.BuildScopedGen2ContextForQuery(history, references, principalID, focusProjectID, request.Query) + candidates := interactionContextCandidates(projection, focusProjectID, experiences, request.Query) + filtered := make([]cognitive.ContextCandidate, 0, len(candidates)) + for _, candidate := range candidates { + if !iterativeLaneMatches(request.Lane, candidate.Lane) { + continue + } + key := string(request.Lane) + ":" + string(candidate.Excerpt.ID) + if _, duplicate := seen[key]; duplicate { + continue + } + filtered = append(filtered, candidate) + } + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: filtered, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: request.RemainingBytes, ContextScopeRef: focusProjectID, TaskRef: "chat", FocusFrameRef: focusProjectID, FreshnessRequirement: "conversation-current"}) + if err != nil || len(bundle.Excerpts) == 0 { + return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "no_additional_evidence"} + } + out := make([]cognitive.ContextCandidate, 0, iterativeRetrievalMaxFragments) + for _, excerpt := range bundle.Excerpts { + if len(out) == iterativeRetrievalMaxFragments || time.Since(started) > iterativeRetrievalMaxLatency { + break + } + seen[string(request.Lane)+":"+string(excerpt.ID)] = struct{}{} + for _, candidate := range filtered { + if candidate.Excerpt.ID == excerpt.ID { + out = append(out, candidate) + break + } + } + } + if len(out) == 0 { + return cognitive.IterativeRetrievalResponse{Exhausted: true, Reason: "latency_budget"} + } + addedBytes := 0 + for _, candidate := range out { + addedBytes += len([]byte(candidate.Excerpt.Text)) + } + return cognitive.IterativeRetrievalResponse{Candidates: out, Exhausted: true, Reason: "completed", AddedBytes: addedBytes} + } +} + +func iterativeLaneMatches(request cognitive.IterativeRetrievalLane, actual string) bool { + return (request == cognitive.IterativeRetrievalCurrentFact && actual == "current-fact") || + (request == cognitive.IterativeRetrievalPositiveExperience && actual == "positive-experience") || + (request == cognitive.IterativeRetrievalNegativeExperience && actual == "negative-experience") || + (request == cognitive.IterativeRetrievalContradiction && actual == "contradiction") || + (request == cognitive.IterativeRetrievalHistoricalAnalogy && actual == "historical-analogy") +} + const ( // Household interpretation and response review are intentionally compact // calls. Keeping their output bounded prevents a loaded local model from @@ -168,43 +236,53 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi // non-current question only after evidence retrieval did not produce a usable // result. The model decides whether its knowledge is stable enough; it must // explicitly decline changing or source-dependent facts instead of guessing. +type iterativeContextRetriever func(context.Context, cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse + func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning) (string, error) { + return c.AnswerStableKnowledgeWithContinuation(ctx, session, userMessage, personalMemory, meaning, nil) +} + +// AnswerStableKnowledgeWithContinuation permits one model-requested, Core-run +// retrieval round. The callback receives only a typed intent and cannot alter +// identity, scope or provider selection. +func (c cognitiveInterpretationClient) AnswerStableKnowledgeWithContinuation(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning, retrieve iterativeContextRetriever) (string, error) { requestID, err := ids.New() if err != nil { return "", err } prompt := "Ты отвечаешь как Домовой на обычное сообщение пользователя. Сначала пойми цель: на вопрос дай короткий естественный ответ по устойчивому общеизвестному знанию, а безопасную просьбу создать текстовый или структурный результат выполни прямо в ответе. Начинай сразу с содержания и не представляйся, если пользователь не спросил об имени. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты и не утверждай выполнение внешнего действия.\n\nСообщение пользователя:\n" + userMessage + if retrieve != nil { + prompt += "\n\nЕсли именно для ответа не хватает ограниченного контекста, вместо ответа верни единственную строку {\"Query\":\"...\",\"SemanticSubject\":\"...\",\"Lane\":\"CURRENT_FACT|POSITIVE_EXPERIENCE|NEGATIVE_EXPERIENCE|CONTRADICTION|HISTORICAL_ANALOGY\",\"Reason\":\"...\"}. Не запрашивай широкий дамп памяти." + } context := []cognitive.ContextExcerpt{} if strings.TrimSpace(personalMemory) != "" { context = append(context, cognitive.ContextExcerpt{ID: "personal-memory", SourceKind: "derived-personal-memory", SourceID: "interaction:human-utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: personalMemory}) } - target := *c.baseURL - target.Path = "/v1/advisories" - payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{ + requestValue := cognitive.GatewayAdvisoryRequest{ Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID), ActorID: session.ActorID, PrincipalID: session.PrincipalID, ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt, Context: context, Locale: "ru", MaxOutputBytes: stableKnowledgeMaxOutputBytes, DataPolicy: capability.DataPolicyLocalOnly, - Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis}, - }) + AllowIterativeRetrieval: retrieve != nil, + Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis}, + } + result, err := c.requestAdvisory(ctx, requestValue) if err != nil { return "", err } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) - if err != nil { - return "", err - } - request.Header.Set("Content-Type", "application/json") - response, err := c.client.Do(request) - if err != nil { - return "", err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return "", fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode) - } - var result cognitive.GatewayAdvisoryResponse - if err := json.NewDecoder(response.Body).Decode(&result); err != nil { - return "", err + if result.IterativeRetrieval != nil && retrieve != nil { + additional := retrieve(ctx, *result.IterativeRetrieval) + for _, candidate := range additional.Candidates { + context = append(context, candidate.Excerpt) + } + if len(additional.Candidates) == 0 { + context = append(context, cognitive.ContextExcerpt{ID: "continuation:no-additional-evidence", SourceKind: "iterative-retrieval", SourceID: "core", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "No additional evidence was found; answer with uncertainty rather than inventing facts."}) + } + requestValue.Context = context + requestValue.AllowIterativeRetrieval = false + result, err = c.requestAdvisory(ctx, requestValue) + if err != nil { + return "", err + } } if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" { return "", fmt.Errorf("cognitive stable knowledge incomplete: %s", result.FinishReason) @@ -212,6 +290,33 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context return strings.TrimSpace(result.AdvisoryText), nil } +func (c cognitiveInterpretationClient) requestAdvisory(ctx context.Context, value cognitive.GatewayAdvisoryRequest) (cognitive.GatewayAdvisoryResponse, error) { + target := *c.baseURL + target.Path = "/v1/advisories" + payload, err := json.Marshal(value) + if err != nil { + return cognitive.GatewayAdvisoryResponse{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) + if err != nil { + return cognitive.GatewayAdvisoryResponse{}, err + } + request.Header.Set("Content-Type", "application/json") + response, err := c.client.Do(request) + if err != nil { + return cognitive.GatewayAdvisoryResponse{}, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return cognitive.GatewayAdvisoryResponse{}, fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode) + } + var result cognitive.GatewayAdvisoryResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return cognitive.GatewayAdvisoryResponse{}, err + } + return result, nil +} + func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) { base, err := url.Parse(rawURL) if err != nil || base.Scheme == "" || base.Host == "" { diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go index ad69395..bdb4458 100644 --- a/cmd/core-runtime/cognitive_interpretation_test.go +++ b/cmd/core-runtime/cognitive_interpretation_test.go @@ -11,6 +11,7 @@ import ( "git.cin.su/m/Assistant/contracts/capability" "git.cin.su/m/Assistant/contracts/cognitive" + "git.cin.su/m/Assistant/contracts/epistemics" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/interaction" interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" @@ -92,6 +93,62 @@ func TestCompileInteractionContextUsesHistoricalOnlyWhenRequested(t *testing.T) } } +func TestStableKnowledgeContinuesOnceWithBoundedAdditionalContext(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var request cognitive.GatewayAdvisoryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + if calls == 1 { + if !request.AllowIterativeRetrieval { + t.Fatal("initial advisory did not permit bounded continuation") + } + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishCompleted, IterativeRetrieval: &cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "initial context is insufficient", RemainingBytes: 512}}) + return + } + if request.AllowIterativeRetrieval || len(request.Context) != 1 || request.Context[0].Text != "контрольный код = ORBIT-731" { + t.Fatalf("continuation request=%+v", request) + } + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, FinishReason: cognitive.FinishCompleted, AdvisoryText: "Контрольный код — ORBIT-731."}) + })) + defer server.Close() + client, err := newCognitiveInterpretationClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + answer, err := client.AnswerStableKnowledgeWithContinuation(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "Какой контрольный код?", "", cognitive.TaskMeaning{SpeechAct: "QUESTION"}, func(context.Context, cognitive.IterativeRetrievalRequest) cognitive.IterativeRetrievalResponse { + return cognitive.IterativeRetrievalResponse{Candidates: []cognitive.ContextCandidate{{Excerpt: cognitive.ContextExcerpt{ID: "interaction:code", Text: "контрольный код = ORBIT-731", Sensitivity: cognitive.SensitivityPrivateAllowed}, Lane: "current-fact"}}, Exhausted: true, AddedBytes: 28} + }) + if err != nil || answer != "Контрольный код — ORBIT-731." || calls != 2 { + t.Fatalf("answer=%q err=%v calls=%d", answer, err, calls) + } +} + +func TestIterativeRetrieverPreservesScopeLanesAndBudgets(t *testing.T) { + history := []interaction.HumanUtterance{{ID: "a", PrincipalID: "p", Content: "контрольный код = ALPHA", ReceivedAtUnixMillis: 1}, {ID: "b", PrincipalID: "p", Content: "контрольный код = BETA", ReceivedAtUnixMillis: 2}} + references := []interaction.ProjectReference{{ID: "a-ref", UtteranceID: "a", ProjectID: "company-a"}, {ID: "b-ref", UtteranceID: "b", ProjectID: "company-b"}} + experiences := []epistemics.ExperienceRecord{{ID: "failure", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "catalog sync", ActualOutcome: epistemics.OutcomeFailed, OutcomeDetail: "old connector failed", RecordedAtUnixMillis: 1}, {ID: "conflict", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "catalog sync", ActualOutcome: epistemics.OutcomeConflict, OutcomeDetail: "conflicting result", RecordedAtUnixMillis: 2}} + retrieve := newIterativeContextRetriever(history, references, "p", "company-a", experiences) + current := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "need scoped fact", RemainingBytes: 512}) + if len(current.Candidates) != 1 || !strings.Contains(current.Candidates[0].Excerpt.Text, "ALPHA") || strings.Contains(current.Candidates[0].Excerpt.Text, "BETA") { + t.Fatalf("scope leaked: %#v", current) + } + if duplicate := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "контрольный код", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "repeat", RemainingBytes: 512}); duplicate.Reason != "no_additional_evidence" { + t.Fatalf("duplicate was re-added: %#v", duplicate) + } + if negative := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "catalog sync", Lane: cognitive.IterativeRetrievalNegativeExperience, Reason: "need failure", RemainingBytes: 512}); len(negative.Candidates) < 1 || !strings.Contains(strings.Join([]string{negative.Candidates[0].Excerpt.Text, negative.Candidates[len(negative.Candidates)-1].Excerpt.Text}, " "), "old connector failed") { + t.Fatalf("negative lane=%#v", negative) + } + if contradiction := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "catalog sync", Lane: cognitive.IterativeRetrievalContradiction, Reason: "need conflict", RemainingBytes: 512}); len(contradiction.Candidates) != 1 || !strings.Contains(contradiction.Candidates[0].Excerpt.Text, "conflicting result") { + t.Fatalf("contradiction lane=%#v", contradiction) + } + if budget := retrieve(context.Background(), cognitive.IterativeRetrievalRequest{Query: "anything", Lane: cognitive.IterativeRetrievalCurrentFact, Reason: "bad", RemainingBytes: 2048}); budget.Reason != "budget" { + t.Fatalf("budget=%#v", budget) + } +} + func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" { diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index e5736e0..2f76c69 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -604,6 +604,7 @@ func main() { } else { gen2Context.Text = compiledContext } + iterativeRetriever := newIterativeContextRetriever(conversationHistory, contextProjectReferences, string(session.PrincipalID), focusProjectID, experienceRecords) if gen2Context.UsedPersonalMemoryFallback { telemetry.RecordCompatibility("personal-memory-fallback", "core-runtime", "read", "gen2-context-empty", true, true, false, "USED", utterance.CorrelationID, string(session.PrincipalID), now) } @@ -931,9 +932,9 @@ func main() { reply, backed := renderResearchReply(searchQuery, result, researchErr) if !backed && cognitiveAvailable { answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second) - answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, part, gen2Context.Text, cognitive.TaskMeaning{ + answer, answerErr := cognitiveClient.AnswerStableKnowledgeWithContinuation(answerCtx, session, part, gen2Context.Text, cognitive.TaskMeaning{ SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis, - }) + }, iterativeRetriever) cancelAnswer() if answerErr == nil && stableKnowledgeAnswer(answer) { reply, backed = answer, true @@ -971,9 +972,9 @@ func main() { canGenerateTextReply := shouldGenerateNonMutatingTextDirective(semantic, routePlan, helpPath, creativeRequest) && planningReply == deterministicReply if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && (!hasChatRoute(routePlan, chatRouteResearch) || len(gen2Context.Fragments) > 0) && !creativeRequest { answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second) - answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, effectiveTaskMessage, gen2Context.Text, cognitive.TaskMeaning{ + answer, answerErr := cognitiveClient.AnswerStableKnowledgeWithContinuation(answerCtx, session, effectiveTaskMessage, gen2Context.Text, cognitive.TaskMeaning{ SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis, - }) + }, iterativeRetriever) cancelAnswer() if answerErr == nil && stableKnowledgeAnswer(answer) { planningReply = answer diff --git a/contracts/cognitive/context_compiler.go b/contracts/cognitive/context_compiler.go index e7aec61..9747896 100644 --- a/contracts/cognitive/context_compiler.go +++ b/contracts/cognitive/context_compiler.go @@ -59,14 +59,54 @@ type ContextAssemblyTrace struct { CompilerVersion string } +type IterativeRetrievalLane string + +const ( + IterativeRetrievalCurrentFact IterativeRetrievalLane = "CURRENT_FACT" + IterativeRetrievalPositiveExperience IterativeRetrievalLane = "POSITIVE_EXPERIENCE" + IterativeRetrievalNegativeExperience IterativeRetrievalLane = "NEGATIVE_EXPERIENCE" + IterativeRetrievalContradiction IterativeRetrievalLane = "CONTRADICTION" + IterativeRetrievalHistoricalAnalogy IterativeRetrievalLane = "HISTORICAL_ANALOGY" +) + +// IterativeRetrievalRequest is the model-visible, storage-agnostic intent. +// Principal, Scope, Focus and policy are deliberately absent: Core binds them +// from the already-authorized request and never accepts them from a model. type IterativeRetrievalRequest struct { - Query string - RemainingBytes int - Pass int + Query string + SemanticSubject string + Lane IterativeRetrievalLane + Reason string + RemainingBytes int + Pass int +} + +// IterativeRetrievalBudget is a Core-enforced per-request budget. +type IterativeRetrievalBudget struct { + MaxRounds int + MaxFragments int + MaxAddedBytes int + MaxLatencyMillis int64 +} + +// IterativeRetrievalEnvelope is the trusted continuation input assembled by +// Core. It is never produced by a provider or model adapter. +type IterativeRetrievalEnvelope struct { + Request IterativeRetrievalRequest + PrincipalID string + ContextScopeRef string + FocusFrameRef string + TaskRef string + FreshnessRequirement string + DataPolicy capability.DataPolicy + Budget IterativeRetrievalBudget + SeenExcerptIDs []ContextExcerptID } type IterativeRetrievalResponse struct { Candidates []ContextCandidate Exhausted bool + Reason string + AddedBytes int } // CompileContext deterministically selects permitted, relevant excerpts for a diff --git a/contracts/cognitive/types.go b/contracts/cognitive/types.go index 465b097..852ddb2 100644 --- a/contracts/cognitive/types.go +++ b/contracts/cognitive/types.go @@ -69,18 +69,19 @@ type AdvisoryRequest struct { // GatewayAdvisoryRequest describes the task and policy requirement. It cannot // name a provider, model or binding; Capability owns that selection. type GatewayAdvisoryRequest struct { - Version ContractVersion - RequestID RequestID - ActorID identity.ActorID - PrincipalID identity.PrincipalID - ScopeID authority.ScopeID - Message string - Context []ContextExcerpt - Locale string - MaxOutputBytes int - DataPolicy capability.DataPolicy - EgressAcknowledged bool - Meaning TaskMeaning + Version ContractVersion + RequestID RequestID + ActorID identity.ActorID + PrincipalID identity.PrincipalID + ScopeID authority.ScopeID + Message string + Context []ContextExcerpt + Locale string + MaxOutputBytes int + DataPolicy capability.DataPolicy + EgressAcknowledged bool + AllowIterativeRetrieval bool + Meaning TaskMeaning } // TaskMeaning is the trusted semantic summary produced by Core. It is not a @@ -177,4 +178,5 @@ type GatewayAdvisoryResponse struct { ReferencedExcerptIDs []ContextExcerptID FinishReason FinishReason Usage *ProviderUsage + IterativeRetrieval *IterativeRetrievalRequest } diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 5b4d7bc..23d0368 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-EVOLVE-001-adaptive-memory-freshness-salience-and-experience.json","task_id":"GEN2-EVOLVE-001","task_revision":3} +{"task_file":"dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json","task_id":"GEN2-EVOLVE-002","task_revision":2} diff --git a/dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json b/dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json new file mode 100644 index 0000000..4a5843a --- /dev/null +++ b/dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json @@ -0,0 +1,23 @@ +{ + "task_id": "GEN2-EVOLVE-002", + "task_revision": 2, + "architecture_revision": "ARCH-GEN2-000", + "base_repo_revision": "gen2-integration", + "risk_tier": "E", + "intent": "Wire one bounded cognitive memory-continuation round through the existing Interaction/Epistemics retrieval fabric and Context Compiler without a new Memory owner, V1 retirement, UI change, direct model storage access or an unrestricted memory tool.", + "requirement_ids": ["REQ-GEN2-EVOLVE-002-ITERATIVE-RETRIEVAL"], + "allowed_change_scope": {"modules": ["cognitive contracts", "core-runtime", "cognitive-worker", "Gen2 governance and evidence"], "paths": ["contracts/cognitive/context_compiler.go", "contracts/cognitive/context_compiler_v2_test.go", "contracts/cognitive/types.go", "cmd/core-runtime/cognitive_interpretation.go", "cmd/core-runtime/cognitive_interpretation_test.go", "cmd/core-runtime/main.go", "cmd/cognitive-worker/gateway.go", "cmd/cognitive-worker/gateway_registry_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/GEN2-EVOLVE-002-iterative-retrieval-and-cognitive-memory-continuation.json", "docs/evidence/**", "docs/PROJECT_STATUS.md"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "go.mod", "go.sum", "database/**", "deploy/**", "cmd/supervisor/**", "cmd/creator-web/**", "PersonalMemory write paths", "GEN2-028", "GEN2-029", "GEN2-030", "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 Interaction and Epistemics retrieval projections", "existing Context Compiler V2"], "may_change": ["Cognitive iterative-retrieval request, inherited continuation envelope and advisory response fields only"]}, + "semantic_owner": "Interaction remains canonical owner of utterance assertions and ProjectReference; Epistemics remains canonical owner of experience. Core validates and binds continuation identity/scope/policy before calling existing owner reads; Context Compiler remains the only context selector; cognitive-worker may emit only a typed retrieval intent and never reads storage.", + "canonical_state": ["no new state is written", "no new Memory owner", "no direct model or cognitive-worker storage access", "no cross-owner SQL join"], + "persistence_and_migrations": ["no schema or migration change"], + "side_effects_and_privileges": ["no external mutation", "no provider override", "no UI/browser change", "no V1 retirement"], + "state_transitions": ["initial Context Compiler context -> cognitive-worker may emit a typed retrieval intent -> Core binds inherited Principal/Scope/Focus/task/freshness/policy -> existing owner retrieval hard-filters scope before ranking -> Context Compiler compiles unique bounded additions -> one continuation advisory request", "no result -> explicit no-additional-evidence continuation", "contradiction or negative experience is lane-classified rather than suppressed"], + "failure_retry_restart": ["one bounded continuation round only", "round/bytes/fragments/latency and duplicate budgets fail closed", "missing evidence remains unknown", "continuation state is request-scoped; durable Work/Investigation remains responsible for any restartable task"], + "assumptions": ["the task branch starts at gen2-integration 850fe92", "UI/browser acceptance remains deferred by owner", "the traced existing IterativeRetrievalRequest lacks the required structured lane/reason and inherited runtime envelope, so this revision changes only those Cognitive contract fields"], + "acceptance_scenarios": ["EVOLVE-002-SCN-01 insufficient initial context can obtain one additional current fact", "EVOLVE-002-SCN-02 negative and contradiction lanes are separately requested", "EVOLVE-002-SCN-03 inherited Company A scope rejects Company B regardless of request", "EVOLVE-002-SCN-04 duplicate excerpts and rounds/byte limits are rejected", "EVOLVE-002-SCN-05 no result preserves an explicit unknown/no-additional-evidence state"], + "required_tests": ["FAST: targeted go test and go vet for changed cognitive contract, core-runtime and cognitive-worker packages", "FAST: devctl preflight, check-diff, independent governance, git diff --check", "MILESTONE: changed owner/runtime tests plus focused bounded continuation and restart-state check; no browser or FULL suite"], + "terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT", "REQUIRES_MIGRATION_DECISION"] +} diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 6b520c1..6f718c4 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -2,6 +2,8 @@ ## Gen2 M6 checkpoint — 2026-08-30 +- `GEN2-EVOLVE-002 = MILESTONE_ACCEPTED`: one bounded typed cognitive memory-continuation round is available through Core and the existing retrieval fabric. Evidence: `docs/evidence/gen2-evolve-002-milestone-2026-08-30.md`. UI and V1 compatibility remain unchanged. + - `GEN2-EVOLVE-001 = MILESTONE_ACCEPTED`: bounded adaptive retrieval adds task-relative freshness, computed salience, positive/negative/contradiction experience lanes and provenance-preserving consolidation candidates. Evidence: `docs/evidence/gen2-evolve-001-milestone-2026-08-30.md`. No V1 path was retired and UI/browser acceptance remains deferred by owner. - `GEN2-028 = COMPLETED_WITH_EVIDENCE`: V1 compatibility inventory is recorded in `docs/evidence/gen2-028-v1-compatibility-inventory-2026-08-30.json`. Current production callers remain for PersonalMemory fallback, ActiveProjectFocus, truth_bundles, ordinary ENV reads, SkillProfile/FastPath; no path was proven `SAFE_TO_RETIRE`. diff --git a/docs/evidence/gen2-evolve-002-milestone-2026-08-30.md b/docs/evidence/gen2-evolve-002-milestone-2026-08-30.md new file mode 100644 index 0000000..1fd141e --- /dev/null +++ b/docs/evidence/gen2-evolve-002-milestone-2026-08-30.md @@ -0,0 +1,14 @@ +# GEN2-EVOLVE-002 — Iterative Retrieval & Cognitive Memory Continuation + +- Task/revision: `GEN2-EVOLVE-002 r2`; base `850fe92`. +- Continuation path: Core initial Context Compiler -> Cognitive Gateway advisory -> typed retrieval intent -> Core-bound inherited scope/policy -> existing Interaction/Epistemics retrieval -> existing Context Compiler -> one final advisory. +- Initiator: the cognitive strategy may emit a strict typed lane intent. Gateway validates it; Core, not the model, binds Principal, ContextScope, Focus, task label, freshness policy and local-only data policy. +- Limits: one continuation round, two excerpts, 1024 added bytes, 1.5 second local retrieval window, lane-qualified duplicate suppression. The second advisory has continuation disabled. +- No result returns a private `no additional evidence` excerpt for the final reasoning round. Negative experience and contradiction are separate lanes; scope is hard-filtered before ranking. +- No new owner, schema, provider selector, direct cognitive-worker storage access, V1 retirement or UI change. + +## Verification + +MILESTONE gate: targeted `go test` and `go vet` for `contracts/cognitive`, `cmd/cognitive-worker`, and `cmd/core-runtime`; `devctl preflight`, `check-diff`, `independent`, `check-architecture`, and `git diff --check`. + +Focused tests cover the second retrieval round, Company A/B isolation, negative and contradiction lanes, duplicate suppression, byte/round limits, and no-additional-evidence behavior. Continuation state is request-scoped; durable restart/resume remains with the existing Work/Investigation owner.