ARCH: добавить policy gate ответа MCP discovery
This commit is contained in:
parent
58c1358960
commit
c744308223
|
|
@ -970,6 +970,39 @@ Validation:
|
|||
- `npm test -- assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryRuntimeEntryPoint.test.ts assistantMcpDiscoveryDebugAttachment.test.ts assistantMcpDiscoveryAnswerAdapter.test.ts assistantMcpDiscoveryPilotExecutor.test.ts` passed 17/17;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Progress Update - 2026-04-20 MCP Discovery Response Policy Gate
|
||||
|
||||
The thirteenth implementation slice of Big Block 5 added the first explicit guarded answer-replacement policy:
|
||||
|
||||
- `assistantMcpDiscoveryResponsePolicy.ts`
|
||||
- `assistantMcpDiscoveryResponsePolicy.test.ts`
|
||||
- living-chat runtime integration for unsupported current-turn meaning boundaries.
|
||||
|
||||
This is the first slice where MCP discovery can affect the user-facing answer, but only through a narrow policy gate.
|
||||
|
||||
The policy can apply a discovery candidate only when all of the following are true:
|
||||
|
||||
- the current living-chat branch is `unsupported_current_turn_meaning_boundary` or its deterministic boundary reply;
|
||||
- the runtime meta contains a valid `assistant_mcp_discovery_runtime_entry_point_v1` contract;
|
||||
- the candidate status is one of `ready_for_guarded_use`, `checked_sources_only_candidate`, or `clarification_candidate`;
|
||||
- `eligible_for_future_hot_runtime=true`;
|
||||
- the candidate has non-empty user-facing text;
|
||||
- the text does not expose internal primitive/query/runtime mechanics.
|
||||
|
||||
When the gate does not pass, the old honest boundary answer is preserved.
|
||||
|
||||
The living-chat debug surface now includes:
|
||||
|
||||
- `mcp_discovery_response_policy_v1`;
|
||||
- `mcp_discovery_response_candidate_v1`;
|
||||
- `mcp_discovery_response_applied`;
|
||||
- the standard MCP discovery entry-point attachment fields on chat-path output.
|
||||
|
||||
Validation:
|
||||
|
||||
- `npm test -- assistantMcpDiscoveryResponsePolicy.test.ts assistantLivingChatRuntimeAdapter.test.ts assistantMcpDiscoveryResponseCandidate.test.ts assistantMcpDiscoveryDebugAttachment.test.ts` passed 19/19;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
Do not implement this plan as:
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|||
exports.runAssistantLivingChatRuntime = runAssistantLivingChatRuntime;
|
||||
const assistantMemoryRecapPolicy_1 = require("./assistantMemoryRecapPolicy");
|
||||
const assistantContinuityPolicy_1 = require("./assistantContinuityPolicy");
|
||||
const assistantMcpDiscoveryDebugAttachment_1 = require("./assistantMcpDiscoveryDebugAttachment");
|
||||
const assistantMcpDiscoveryResponsePolicy_1 = require("./assistantMcpDiscoveryResponsePolicy");
|
||||
function hasPriorAssistantTurn(items) {
|
||||
if (!Array.isArray(items)) {
|
||||
return false;
|
||||
|
|
@ -228,6 +230,17 @@ async function runAssistantLivingChatRuntime(input) {
|
|||
debug: null
|
||||
};
|
||||
}
|
||||
const mcpDiscoveryResponsePolicy = (0, assistantMcpDiscoveryResponsePolicy_1.applyAssistantMcpDiscoveryResponsePolicy)({
|
||||
currentReply: chatText,
|
||||
currentReplySource: livingChatSource,
|
||||
livingChatSource,
|
||||
modeDecisionReason: input.modeDecision?.reason ?? null,
|
||||
addressRuntimeMeta
|
||||
});
|
||||
if (mcpDiscoveryResponsePolicy.applied) {
|
||||
chatText = mcpDiscoveryResponsePolicy.reply_text;
|
||||
livingChatSource = mcpDiscoveryResponsePolicy.reply_source;
|
||||
}
|
||||
const predecomposeContract = addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object"
|
||||
? addressRuntimeMeta.predecomposeContract
|
||||
: null;
|
||||
|
|
@ -245,6 +258,9 @@ async function runAssistantLivingChatRuntime(input) {
|
|||
living_router_mode: input.modeDecision?.mode ?? "chat",
|
||||
living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected",
|
||||
living_chat_response_source: livingChatSource,
|
||||
mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy,
|
||||
mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate,
|
||||
mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied,
|
||||
living_chat_script_guard_applied: livingChatScriptGuardApplied,
|
||||
living_chat_script_guard_reason: livingChatScriptGuardReason,
|
||||
living_chat_grounding_guard_applied: livingChatGroundingGuardApplied,
|
||||
|
|
@ -278,6 +294,6 @@ async function runAssistantLivingChatRuntime(input) {
|
|||
return {
|
||||
handled: true,
|
||||
chatText,
|
||||
debug
|
||||
debug: (0, assistantMcpDiscoveryDebugAttachment_1.attachAssistantMcpDiscoveryDebug)(debug, { addressRuntimeMeta })
|
||||
};
|
||||
}
|
||||
|
|
|
|||
122
llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js
vendored
Normal file
122
llm_normalizer/backend/dist/services/assistantMcpDiscoveryResponsePolicy.js
vendored
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = void 0;
|
||||
exports.applyAssistantMcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy;
|
||||
const assistantMcpDiscoveryResponseCandidate_1 = require("./assistantMcpDiscoveryResponseCandidate");
|
||||
exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION = "assistant_mcp_discovery_response_policy_v1";
|
||||
const ALLOWED_CANDIDATE_STATUSES = new Set([
|
||||
"ready_for_guarded_use",
|
||||
"checked_sources_only_candidate",
|
||||
"clarification_candidate"
|
||||
]);
|
||||
function toRecordObject(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
function toNonEmptyString(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
function normalizeReasonCode(value) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
return normalized.length > 0 ? normalized.slice(0, 120) : null;
|
||||
}
|
||||
function pushReason(target, value) {
|
||||
const normalized = normalizeReasonCode(value);
|
||||
if (normalized && !target.includes(normalized)) {
|
||||
target.push(normalized);
|
||||
}
|
||||
}
|
||||
function hasInternalMechanics(value) {
|
||||
const text = value.toLowerCase();
|
||||
return (text.includes("query_documents") ||
|
||||
text.includes("query_movements") ||
|
||||
text.includes("primitive") ||
|
||||
text.includes("runtime_") ||
|
||||
text.includes("planner_") ||
|
||||
text.includes("catalog_") ||
|
||||
text.includes("select "));
|
||||
}
|
||||
function isMcpDiscoveryEntryPointContract(value) {
|
||||
const record = toRecordObject(value);
|
||||
return (record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" &&
|
||||
record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint");
|
||||
}
|
||||
function resolveEntryPoint(input) {
|
||||
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
|
||||
return input.entryPoint;
|
||||
}
|
||||
const runtimeMetaEntryPoint = input.addressRuntimeMeta?.mcpDiscoveryRuntimeEntryPoint ??
|
||||
input.addressRuntimeMeta?.assistantMcpDiscoveryRuntimeEntryPoint ??
|
||||
input.addressRuntimeMeta?.assistant_mcp_discovery_entry_point_v1;
|
||||
return isMcpDiscoveryEntryPointContract(runtimeMetaEntryPoint) ? runtimeMetaEntryPoint : null;
|
||||
}
|
||||
function isUnsupportedCurrentTurnBoundary(input) {
|
||||
return (input.modeDecisionReason === "unsupported_current_turn_meaning_boundary" ||
|
||||
input.livingChatSource === "deterministic_unsupported_current_turn_boundary" ||
|
||||
input.currentReplySource === "deterministic_unsupported_current_turn_boundary");
|
||||
}
|
||||
function applyAssistantMcpDiscoveryResponsePolicy(input) {
|
||||
const currentReply = String(input.currentReply ?? "");
|
||||
const currentReplySource = toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown";
|
||||
const entryPoint = resolveEntryPoint(input);
|
||||
const candidate = (0, assistantMcpDiscoveryResponseCandidate_1.buildAssistantMcpDiscoveryResponseCandidate)(entryPoint);
|
||||
const reasonCodes = [...candidate.reason_codes];
|
||||
if (!entryPoint) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
|
||||
}
|
||||
if (!isUnsupportedCurrentTurnBoundary(input)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_not_unsupported_boundary");
|
||||
}
|
||||
if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed");
|
||||
}
|
||||
if (!candidate.eligible_for_future_hot_runtime) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible");
|
||||
}
|
||||
if (!toNonEmptyString(candidate.reply_text)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text");
|
||||
}
|
||||
if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics");
|
||||
}
|
||||
const canApply = Boolean(entryPoint) &&
|
||||
isUnsupportedCurrentTurnBoundary(input) &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
Boolean(toNonEmptyString(candidate.reply_text)) &&
|
||||
!hasInternalMechanics(String(candidate.reply_text ?? ""));
|
||||
if (!canApply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_kept_current_reply");
|
||||
return {
|
||||
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "keep_current_reply",
|
||||
applied: false,
|
||||
reply_text: currentReply,
|
||||
reply_source: currentReplySource,
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
|
||||
return {
|
||||
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "apply_candidate",
|
||||
applied: true,
|
||||
reply_text: String(candidate.reply_text),
|
||||
reply_source: "mcp_discovery_response_candidate_guarded",
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import {
|
|||
resolveAssistantLivingChatMemoryContext
|
||||
} from "./assistantMemoryRecapPolicy";
|
||||
import { resolveAssistantOrganizationAuthority } from "./assistantContinuityPolicy";
|
||||
import { attachAssistantMcpDiscoveryDebug } from "./assistantMcpDiscoveryDebugAttachment";
|
||||
import { applyAssistantMcpDiscoveryResponsePolicy } from "./assistantMcpDiscoveryResponsePolicy";
|
||||
|
||||
export interface AssistantLivingChatSessionScopeInput {
|
||||
knownOrganizations?: unknown[];
|
||||
|
|
@ -305,6 +307,18 @@ export async function runAssistantLivingChatRuntime(
|
|||
};
|
||||
}
|
||||
|
||||
const mcpDiscoveryResponsePolicy = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: chatText,
|
||||
currentReplySource: livingChatSource,
|
||||
livingChatSource,
|
||||
modeDecisionReason: input.modeDecision?.reason ?? null,
|
||||
addressRuntimeMeta
|
||||
});
|
||||
if (mcpDiscoveryResponsePolicy.applied) {
|
||||
chatText = mcpDiscoveryResponsePolicy.reply_text;
|
||||
livingChatSource = mcpDiscoveryResponsePolicy.reply_source;
|
||||
}
|
||||
|
||||
const predecomposeContract =
|
||||
addressRuntimeMeta.predecomposeContract && typeof addressRuntimeMeta.predecomposeContract === "object"
|
||||
? (addressRuntimeMeta.predecomposeContract as Record<string, unknown>)
|
||||
|
|
@ -325,6 +339,9 @@ export async function runAssistantLivingChatRuntime(
|
|||
living_router_mode: input.modeDecision?.mode ?? "chat",
|
||||
living_router_reason: input.modeDecision?.reason ?? "living_chat_signal_detected",
|
||||
living_chat_response_source: livingChatSource,
|
||||
mcp_discovery_response_policy_v1: mcpDiscoveryResponsePolicy,
|
||||
mcp_discovery_response_candidate_v1: mcpDiscoveryResponsePolicy.candidate,
|
||||
mcp_discovery_response_applied: mcpDiscoveryResponsePolicy.applied,
|
||||
living_chat_script_guard_applied: livingChatScriptGuardApplied,
|
||||
living_chat_script_guard_reason: livingChatScriptGuardReason,
|
||||
living_chat_grounding_guard_applied: livingChatGroundingGuardApplied,
|
||||
|
|
@ -359,6 +376,6 @@ export async function runAssistantLivingChatRuntime(
|
|||
return {
|
||||
handled: true,
|
||||
chatText,
|
||||
debug
|
||||
debug: attachAssistantMcpDiscoveryDebug(debug, { addressRuntimeMeta })
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,174 @@
|
|||
import {
|
||||
buildAssistantMcpDiscoveryResponseCandidate,
|
||||
type AssistantMcpDiscoveryResponseCandidateContract,
|
||||
type AssistantMcpDiscoveryResponseCandidateStatus
|
||||
} from "./assistantMcpDiscoveryResponseCandidate";
|
||||
import type { AssistantMcpDiscoveryRuntimeEntryPointContract } from "./assistantMcpDiscoveryRuntimeEntryPoint";
|
||||
|
||||
export const ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION =
|
||||
"assistant_mcp_discovery_response_policy_v1" as const;
|
||||
|
||||
export type AssistantMcpDiscoveryResponsePolicyDecision = "apply_candidate" | "keep_current_reply";
|
||||
|
||||
export interface ApplyAssistantMcpDiscoveryResponsePolicyInput {
|
||||
currentReply: string;
|
||||
currentReplySource?: string | null;
|
||||
livingChatSource?: string | null;
|
||||
modeDecisionReason?: string | null;
|
||||
addressRuntimeMeta?: Record<string, unknown> | null;
|
||||
entryPoint?: unknown;
|
||||
}
|
||||
|
||||
export interface AssistantMcpDiscoveryResponsePolicyResult {
|
||||
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION;
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy";
|
||||
decision: AssistantMcpDiscoveryResponsePolicyDecision;
|
||||
applied: boolean;
|
||||
reply_text: string;
|
||||
reply_source: string;
|
||||
candidate: AssistantMcpDiscoveryResponseCandidateContract;
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
const ALLOWED_CANDIDATE_STATUSES = new Set<AssistantMcpDiscoveryResponseCandidateStatus>([
|
||||
"ready_for_guarded_use",
|
||||
"checked_sources_only_candidate",
|
||||
"clarification_candidate"
|
||||
]);
|
||||
|
||||
function toRecordObject(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function toNonEmptyString(value: unknown): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function normalizeReasonCode(value: string): string | null {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}_.:-]+/gu, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
return normalized.length > 0 ? normalized.slice(0, 120) : null;
|
||||
}
|
||||
|
||||
function pushReason(target: string[], value: string): void {
|
||||
const normalized = normalizeReasonCode(value);
|
||||
if (normalized && !target.includes(normalized)) {
|
||||
target.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
function hasInternalMechanics(value: string): boolean {
|
||||
const text = value.toLowerCase();
|
||||
return (
|
||||
text.includes("query_documents") ||
|
||||
text.includes("query_movements") ||
|
||||
text.includes("primitive") ||
|
||||
text.includes("runtime_") ||
|
||||
text.includes("planner_") ||
|
||||
text.includes("catalog_") ||
|
||||
text.includes("select ")
|
||||
);
|
||||
}
|
||||
|
||||
function isMcpDiscoveryEntryPointContract(value: unknown): value is AssistantMcpDiscoveryRuntimeEntryPointContract {
|
||||
const record = toRecordObject(value);
|
||||
return (
|
||||
record?.schema_version === "assistant_mcp_discovery_runtime_entry_point_v1" &&
|
||||
record?.policy_owner === "assistantMcpDiscoveryRuntimeEntryPoint"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveEntryPoint(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput
|
||||
): AssistantMcpDiscoveryRuntimeEntryPointContract | null {
|
||||
if (isMcpDiscoveryEntryPointContract(input.entryPoint)) {
|
||||
return input.entryPoint;
|
||||
}
|
||||
const runtimeMetaEntryPoint =
|
||||
input.addressRuntimeMeta?.mcpDiscoveryRuntimeEntryPoint ??
|
||||
input.addressRuntimeMeta?.assistantMcpDiscoveryRuntimeEntryPoint ??
|
||||
input.addressRuntimeMeta?.assistant_mcp_discovery_entry_point_v1;
|
||||
return isMcpDiscoveryEntryPointContract(runtimeMetaEntryPoint) ? runtimeMetaEntryPoint : null;
|
||||
}
|
||||
|
||||
function isUnsupportedCurrentTurnBoundary(input: ApplyAssistantMcpDiscoveryResponsePolicyInput): boolean {
|
||||
return (
|
||||
input.modeDecisionReason === "unsupported_current_turn_meaning_boundary" ||
|
||||
input.livingChatSource === "deterministic_unsupported_current_turn_boundary" ||
|
||||
input.currentReplySource === "deterministic_unsupported_current_turn_boundary"
|
||||
);
|
||||
}
|
||||
|
||||
export function applyAssistantMcpDiscoveryResponsePolicy(
|
||||
input: ApplyAssistantMcpDiscoveryResponsePolicyInput
|
||||
): AssistantMcpDiscoveryResponsePolicyResult {
|
||||
const currentReply = String(input.currentReply ?? "");
|
||||
const currentReplySource =
|
||||
toNonEmptyString(input.currentReplySource) ?? toNonEmptyString(input.livingChatSource) ?? "unknown";
|
||||
const entryPoint = resolveEntryPoint(input);
|
||||
const candidate = buildAssistantMcpDiscoveryResponseCandidate(entryPoint);
|
||||
const reasonCodes = [...candidate.reason_codes];
|
||||
|
||||
if (!entryPoint) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_no_entry_point");
|
||||
}
|
||||
if (!isUnsupportedCurrentTurnBoundary(input)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_not_unsupported_boundary");
|
||||
}
|
||||
if (!ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_status_not_allowed");
|
||||
}
|
||||
if (!candidate.eligible_for_future_hot_runtime) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_not_eligible");
|
||||
}
|
||||
if (!toNonEmptyString(candidate.reply_text)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_missing_reply_text");
|
||||
}
|
||||
if (candidate.reply_text && hasInternalMechanics(candidate.reply_text)) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_contains_internal_mechanics");
|
||||
}
|
||||
|
||||
const canApply =
|
||||
Boolean(entryPoint) &&
|
||||
isUnsupportedCurrentTurnBoundary(input) &&
|
||||
ALLOWED_CANDIDATE_STATUSES.has(candidate.candidate_status) &&
|
||||
candidate.eligible_for_future_hot_runtime &&
|
||||
Boolean(toNonEmptyString(candidate.reply_text)) &&
|
||||
!hasInternalMechanics(String(candidate.reply_text ?? ""));
|
||||
|
||||
if (!canApply) {
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_kept_current_reply");
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "keep_current_reply",
|
||||
applied: false,
|
||||
reply_text: currentReply,
|
||||
reply_source: currentReplySource,
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
|
||||
pushReason(reasonCodes, "mcp_discovery_response_policy_candidate_applied");
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_RESPONSE_POLICY_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryResponsePolicy",
|
||||
decision: "apply_candidate",
|
||||
applied: true,
|
||||
reply_text: String(candidate.reply_text),
|
||||
reply_source: "mcp_discovery_response_candidate_guarded",
|
||||
candidate,
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
|
|
@ -169,6 +169,66 @@ describe("assistant living chat runtime adapter", () => {
|
|||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces unsupported boundary with guarded MCP discovery response when policy allows it", async () => {
|
||||
const executeLlmChat = vi.fn(async () => "raw-llm");
|
||||
const input = buildRuntimeInput({
|
||||
userMessage: "how long has svk been active",
|
||||
modeDecision: { mode: "chat", reason: "unsupported_current_turn_meaning_boundary" },
|
||||
addressRuntimeMeta: {
|
||||
toolGateReason: "unsupported_current_turn_meaning_boundary",
|
||||
orchestrationContract: {
|
||||
unsupported_current_turn_meaning_boundary: true,
|
||||
assistant_turn_meaning: {
|
||||
unsupported_but_understood_family: "counterparty_lifecycle_or_age",
|
||||
explicit_entity_candidates: [
|
||||
{
|
||||
type: "counterparty",
|
||||
value: "SVK"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
mcpDiscoveryRuntimeEntryPoint: {
|
||||
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
|
||||
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
|
||||
entry_status: "bridge_executed",
|
||||
hot_runtime_wired: false,
|
||||
discovery_attempted: true,
|
||||
turn_input: { adapter_status: "ready" },
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Confirmed scoped answer.",
|
||||
confirmed_lines: ["Confirmed fact"],
|
||||
inference_lines: ["Bounded inference"],
|
||||
unknown_lines: ["Unconfirmed legal fact"],
|
||||
limitation_lines: ["Limited source window"],
|
||||
next_step_line: null
|
||||
}
|
||||
},
|
||||
reason_codes: ["runtime_entry_point_bridge_executed"]
|
||||
}
|
||||
},
|
||||
executeLlmChat
|
||||
});
|
||||
|
||||
const output = await runAssistantLivingChatRuntime(input);
|
||||
|
||||
expect(output.handled).toBe(true);
|
||||
expect(output.chatText).toContain("Confirmed fact");
|
||||
expect(output.chatText).not.toContain("route");
|
||||
expect(output.chatText).not.toContain("query_documents");
|
||||
expect(output.debug?.living_chat_response_source).toBe("mcp_discovery_response_candidate_guarded");
|
||||
expect(output.debug?.mcp_discovery_response_applied).toBe(true);
|
||||
expect(output.debug?.mcp_discovery_entry_status).toBe("bridge_executed");
|
||||
expect(output.debug?.mcp_discovery_attempted).toBe(true);
|
||||
expect(executeLlmChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds proactive organization offer on first smalltalk turn when multiple organizations are available", async () => {
|
||||
const resolveDataScopeProbe = vi.fn(async () => ({
|
||||
status: "resolved",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { applyAssistantMcpDiscoveryResponsePolicy } from "../src/services/assistantMcpDiscoveryResponsePolicy";
|
||||
|
||||
function entryPoint(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
schema_version: "assistant_mcp_discovery_runtime_entry_point_v1",
|
||||
policy_owner: "assistantMcpDiscoveryRuntimeEntryPoint",
|
||||
entry_status: "bridge_executed",
|
||||
hot_runtime_wired: false,
|
||||
discovery_attempted: true,
|
||||
turn_input: { adapter_status: "ready" },
|
||||
bridge: {
|
||||
bridge_status: "answer_draft_ready",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: true,
|
||||
requires_user_clarification: false,
|
||||
answer_draft: {
|
||||
answer_mode: "confirmed_with_bounded_inference",
|
||||
headline: "Confirmed scoped answer.",
|
||||
confirmed_lines: ["Confirmed fact"],
|
||||
inference_lines: ["Bounded inference"],
|
||||
unknown_lines: ["Unconfirmed fact"],
|
||||
limitation_lines: ["Limited source window"],
|
||||
next_step_line: null
|
||||
}
|
||||
},
|
||||
reason_codes: ["runtime_entry_point_bridge_executed"],
|
||||
...overrides
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("assistant MCP discovery response policy", () => {
|
||||
it("applies a guarded candidate only for unsupported current-turn boundary replies", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "route is not wired",
|
||||
currentReplySource: "deterministic_unsupported_current_turn_boundary",
|
||||
modeDecisionReason: "unsupported_current_turn_meaning_boundary",
|
||||
addressRuntimeMeta: {
|
||||
mcpDiscoveryRuntimeEntryPoint: entryPoint()
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(true);
|
||||
expect(result.decision).toBe("apply_candidate");
|
||||
expect(result.reply_source).toBe("mcp_discovery_response_candidate_guarded");
|
||||
expect(result.reply_text).toContain("Confirmed fact");
|
||||
expect(result.reply_text).not.toContain("query_documents");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_applied");
|
||||
});
|
||||
|
||||
it("keeps the current reply when the turn is not an unsupported boundary", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "regular chat",
|
||||
currentReplySource: "llm_chat",
|
||||
modeDecisionReason: "living_chat_signal_detected",
|
||||
addressRuntimeMeta: {
|
||||
mcpDiscoveryRuntimeEntryPoint: entryPoint()
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.decision).toBe("keep_current_reply");
|
||||
expect(result.reply_text).toBe("regular chat");
|
||||
expect(result.reply_source).toBe("llm_chat");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_not_unsupported_boundary");
|
||||
});
|
||||
|
||||
it("keeps the current reply when the candidate has no grounded text", () => {
|
||||
const result = applyAssistantMcpDiscoveryResponsePolicy({
|
||||
currentReply: "route is not wired",
|
||||
currentReplySource: "deterministic_unsupported_current_turn_boundary",
|
||||
modeDecisionReason: "unsupported_current_turn_meaning_boundary",
|
||||
addressRuntimeMeta: {
|
||||
mcpDiscoveryRuntimeEntryPoint: entryPoint({
|
||||
bridge: {
|
||||
bridge_status: "unsupported",
|
||||
user_facing_response_allowed: true,
|
||||
business_fact_answer_allowed: false,
|
||||
requires_user_clarification: false,
|
||||
answer_draft: null
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
expect(result.applied).toBe(false);
|
||||
expect(result.reply_text).toBe("route is not wired");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_candidate_not_eligible");
|
||||
expect(result.reason_codes).toContain("mcp_discovery_response_policy_kept_current_reply");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue