ARCH: добавить dry-run adapter MCP discovery
This commit is contained in:
parent
98988fa635
commit
9d5ae69953
|
|
@ -733,6 +733,32 @@ Validation:
|
|||
- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts` passed 17/17;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Progress Update - 2026-04-20 MCP Discovery Runtime Dry-Run Adapter
|
||||
|
||||
The fourth implementation slice of Big Block 5 added a dry-run runtime adapter:
|
||||
|
||||
- `assistantMcpDiscoveryRuntimeAdapter.ts`
|
||||
- `assistantMcpDiscoveryRuntimeAdapter.test.ts`
|
||||
|
||||
This adapter still does not execute live MCP calls.
|
||||
|
||||
It turns planner output into an execution package that future live wiring can consume safely:
|
||||
|
||||
- ordered execution steps;
|
||||
- primitive purpose and expected fact kinds;
|
||||
- provided and missing grounding axes;
|
||||
- evidence floor and stop condition per primitive;
|
||||
- execution budget;
|
||||
- mandatory evidence gate inputs;
|
||||
- user-facing fallback for missing scope or policy blocks.
|
||||
|
||||
The contract explicitly records `mcp_execution_performed=false`, so this block cannot accidentally query 1C.
|
||||
|
||||
Validation:
|
||||
|
||||
- `npm test -- assistantMcpDiscoveryPolicy.test.ts assistantMcpCatalogIndex.test.ts assistantMcpDiscoveryPlanner.test.ts assistantMcpDiscoveryRuntimeAdapter.test.ts` passed 21/21;
|
||||
- `npm run build` passed.
|
||||
|
||||
## Execution Rule
|
||||
|
||||
Do not implement this plan as:
|
||||
|
|
|
|||
129
llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js
vendored
Normal file
129
llm_normalizer/backend/dist/services/assistantMcpDiscoveryRuntimeAdapter.js
vendored
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION = void 0;
|
||||
exports.buildAssistantMcpDiscoveryRuntimeDryRun = buildAssistantMcpDiscoveryRuntimeDryRun;
|
||||
const assistantMcpCatalogIndex_1 = require("./assistantMcpCatalogIndex");
|
||||
exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION = "assistant_mcp_discovery_runtime_dry_run_v1";
|
||||
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 uniqueStrings(values) {
|
||||
const result = [];
|
||||
for (const value of values) {
|
||||
const text = value.trim();
|
||||
if (text && !result.includes(text)) {
|
||||
result.push(text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function stepStatusFor(input) {
|
||||
if (input.adapterStatus === "blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (input.missingAxisOptions.length > 0) {
|
||||
return "missing_axes";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
function stopConditionFor(evidenceFloor) {
|
||||
if (evidenceFloor === "rows_matched") {
|
||||
return "stop_after_allowed_probe_returns_matched_rows_or_reports_no_match";
|
||||
}
|
||||
if (evidenceFloor === "rows_received") {
|
||||
return "stop_after_allowed_probe_returns_rows_or_reports_empty_source";
|
||||
}
|
||||
if (evidenceFloor === "source_summary") {
|
||||
return "stop_after_allowed_probe_returns_source_summary_or_limitation";
|
||||
}
|
||||
return "stop_without_fact_claim";
|
||||
}
|
||||
function adapterStatusFor(planner) {
|
||||
if (planner.planner_status === "blocked" || planner.catalog_review.review_status === "catalog_blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (planner.planner_status !== "ready_for_execution" || planner.catalog_review.review_status !== "catalog_compatible") {
|
||||
return "needs_clarification";
|
||||
}
|
||||
return "dry_run_ready";
|
||||
}
|
||||
function fallbackFor(status) {
|
||||
if (status === "dry_run_ready") {
|
||||
return null;
|
||||
}
|
||||
if (status === "blocked") {
|
||||
return "explain_that_runtime_policy_blocked_mcp_discovery";
|
||||
}
|
||||
return "ask_for_missing_scope_before_mcp_discovery";
|
||||
}
|
||||
function buildAssistantMcpDiscoveryRuntimeDryRun(planner) {
|
||||
const adapterStatus = adapterStatusFor(planner);
|
||||
const reasonCodes = uniqueStrings([
|
||||
...planner.reason_codes,
|
||||
...planner.discovery_plan.reason_codes,
|
||||
...planner.catalog_review.reason_codes
|
||||
]);
|
||||
const providedAxes = uniqueStrings(planner.required_axes);
|
||||
const executionSteps = planner.discovery_plan.allowed_primitives.map((primitiveId, index) => {
|
||||
const catalog = (0, assistantMcpCatalogIndex_1.getAssistantMcpCatalogPrimitive)(primitiveId);
|
||||
const missingAxisOptions = planner.catalog_review.missing_axes_by_primitive[primitiveId] ?? [];
|
||||
return {
|
||||
sequence: index + 1,
|
||||
primitive_id: primitiveId,
|
||||
step_status: stepStatusFor({ adapterStatus, missingAxisOptions }),
|
||||
purpose: catalog.purpose,
|
||||
provided_axes: providedAxes,
|
||||
required_axis_options: catalog.required_axes_any_of,
|
||||
missing_axis_options: missingAxisOptions,
|
||||
optional_axes: catalog.optional_axes,
|
||||
expected_fact_kinds: catalog.output_fact_kinds,
|
||||
evidence_floor: catalog.evidence_floor,
|
||||
runtime_must_execute: true,
|
||||
dry_run_only: true,
|
||||
stop_condition: stopConditionFor(catalog.evidence_floor)
|
||||
};
|
||||
});
|
||||
if (adapterStatus === "dry_run_ready") {
|
||||
pushReason(reasonCodes, "runtime_dry_run_ready_without_mcp_execution");
|
||||
}
|
||||
else if (adapterStatus === "blocked") {
|
||||
pushReason(reasonCodes, "runtime_dry_run_blocked_before_mcp_execution");
|
||||
}
|
||||
else {
|
||||
pushReason(reasonCodes, "runtime_dry_run_needs_clarification_before_mcp_execution");
|
||||
}
|
||||
return {
|
||||
schema_version: exports.ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryRuntimeAdapter",
|
||||
adapter_status: adapterStatus,
|
||||
planner_status: planner.planner_status,
|
||||
mcp_execution_performed: false,
|
||||
execution_steps: executionSteps,
|
||||
execution_budget: planner.discovery_plan.execution_budget,
|
||||
evidence_gate: {
|
||||
required: true,
|
||||
expected_inputs: [
|
||||
"probe_results",
|
||||
"confirmed_facts",
|
||||
"inferred_facts",
|
||||
"unknown_facts",
|
||||
"source_rows_summary",
|
||||
"query_limitations"
|
||||
],
|
||||
answer_may_use_raw_model_claims: false
|
||||
},
|
||||
user_facing_fallback: fallbackFor(adapterStatus),
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import {
|
||||
getAssistantMcpCatalogPrimitive,
|
||||
type AssistantMcpCatalogEvidenceFloor
|
||||
} from "./assistantMcpCatalogIndex";
|
||||
import {
|
||||
type AssistantMcpDiscoveryPlannerContract,
|
||||
type AssistantMcpDiscoveryPlannerStatus
|
||||
} from "./assistantMcpDiscoveryPlanner";
|
||||
import type { AssistantMcpDiscoveryPrimitive } from "./assistantMcpDiscoveryPolicy";
|
||||
|
||||
export const ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION =
|
||||
"assistant_mcp_discovery_runtime_dry_run_v1" as const;
|
||||
|
||||
export type AssistantMcpDiscoveryRuntimeAdapterStatus =
|
||||
| "dry_run_ready"
|
||||
| "needs_clarification"
|
||||
| "blocked";
|
||||
|
||||
export type AssistantMcpDiscoveryRuntimeStepStatus = "ready" | "missing_axes" | "blocked";
|
||||
|
||||
export interface AssistantMcpDiscoveryRuntimeStepContract {
|
||||
sequence: number;
|
||||
primitive_id: AssistantMcpDiscoveryPrimitive;
|
||||
step_status: AssistantMcpDiscoveryRuntimeStepStatus;
|
||||
purpose: string;
|
||||
provided_axes: string[];
|
||||
required_axis_options: string[][];
|
||||
missing_axis_options: string[][];
|
||||
optional_axes: string[];
|
||||
expected_fact_kinds: string[];
|
||||
evidence_floor: AssistantMcpCatalogEvidenceFloor;
|
||||
runtime_must_execute: true;
|
||||
dry_run_only: true;
|
||||
stop_condition: string;
|
||||
}
|
||||
|
||||
export interface AssistantMcpDiscoveryEvidenceGateRequirement {
|
||||
required: true;
|
||||
expected_inputs: string[];
|
||||
answer_may_use_raw_model_claims: false;
|
||||
}
|
||||
|
||||
export interface AssistantMcpDiscoveryRuntimeDryRunContract {
|
||||
schema_version: typeof ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION;
|
||||
policy_owner: "assistantMcpDiscoveryRuntimeAdapter";
|
||||
adapter_status: AssistantMcpDiscoveryRuntimeAdapterStatus;
|
||||
planner_status: AssistantMcpDiscoveryPlannerStatus;
|
||||
mcp_execution_performed: false;
|
||||
execution_steps: AssistantMcpDiscoveryRuntimeStepContract[];
|
||||
execution_budget: {
|
||||
max_probe_count: number;
|
||||
max_rows_per_probe: number;
|
||||
};
|
||||
evidence_gate: AssistantMcpDiscoveryEvidenceGateRequirement;
|
||||
user_facing_fallback: string | null;
|
||||
reason_codes: string[];
|
||||
}
|
||||
|
||||
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 uniqueStrings(values: string[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const text = value.trim();
|
||||
if (text && !result.includes(text)) {
|
||||
result.push(text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stepStatusFor(input: {
|
||||
adapterStatus: AssistantMcpDiscoveryRuntimeAdapterStatus;
|
||||
missingAxisOptions: string[][];
|
||||
}): AssistantMcpDiscoveryRuntimeStepStatus {
|
||||
if (input.adapterStatus === "blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (input.missingAxisOptions.length > 0) {
|
||||
return "missing_axes";
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
function stopConditionFor(evidenceFloor: AssistantMcpCatalogEvidenceFloor): string {
|
||||
if (evidenceFloor === "rows_matched") {
|
||||
return "stop_after_allowed_probe_returns_matched_rows_or_reports_no_match";
|
||||
}
|
||||
if (evidenceFloor === "rows_received") {
|
||||
return "stop_after_allowed_probe_returns_rows_or_reports_empty_source";
|
||||
}
|
||||
if (evidenceFloor === "source_summary") {
|
||||
return "stop_after_allowed_probe_returns_source_summary_or_limitation";
|
||||
}
|
||||
return "stop_without_fact_claim";
|
||||
}
|
||||
|
||||
function adapterStatusFor(planner: AssistantMcpDiscoveryPlannerContract): AssistantMcpDiscoveryRuntimeAdapterStatus {
|
||||
if (planner.planner_status === "blocked" || planner.catalog_review.review_status === "catalog_blocked") {
|
||||
return "blocked";
|
||||
}
|
||||
if (planner.planner_status !== "ready_for_execution" || planner.catalog_review.review_status !== "catalog_compatible") {
|
||||
return "needs_clarification";
|
||||
}
|
||||
return "dry_run_ready";
|
||||
}
|
||||
|
||||
function fallbackFor(status: AssistantMcpDiscoveryRuntimeAdapterStatus): string | null {
|
||||
if (status === "dry_run_ready") {
|
||||
return null;
|
||||
}
|
||||
if (status === "blocked") {
|
||||
return "explain_that_runtime_policy_blocked_mcp_discovery";
|
||||
}
|
||||
return "ask_for_missing_scope_before_mcp_discovery";
|
||||
}
|
||||
|
||||
export function buildAssistantMcpDiscoveryRuntimeDryRun(
|
||||
planner: AssistantMcpDiscoveryPlannerContract
|
||||
): AssistantMcpDiscoveryRuntimeDryRunContract {
|
||||
const adapterStatus = adapterStatusFor(planner);
|
||||
const reasonCodes = uniqueStrings([
|
||||
...planner.reason_codes,
|
||||
...planner.discovery_plan.reason_codes,
|
||||
...planner.catalog_review.reason_codes
|
||||
]);
|
||||
|
||||
const providedAxes = uniqueStrings(planner.required_axes);
|
||||
const executionSteps = planner.discovery_plan.allowed_primitives.map((primitiveId, index) => {
|
||||
const catalog = getAssistantMcpCatalogPrimitive(primitiveId);
|
||||
const missingAxisOptions = planner.catalog_review.missing_axes_by_primitive[primitiveId] ?? [];
|
||||
return {
|
||||
sequence: index + 1,
|
||||
primitive_id: primitiveId,
|
||||
step_status: stepStatusFor({ adapterStatus, missingAxisOptions }),
|
||||
purpose: catalog.purpose,
|
||||
provided_axes: providedAxes,
|
||||
required_axis_options: catalog.required_axes_any_of,
|
||||
missing_axis_options: missingAxisOptions,
|
||||
optional_axes: catalog.optional_axes,
|
||||
expected_fact_kinds: catalog.output_fact_kinds,
|
||||
evidence_floor: catalog.evidence_floor,
|
||||
runtime_must_execute: true,
|
||||
dry_run_only: true,
|
||||
stop_condition: stopConditionFor(catalog.evidence_floor)
|
||||
} satisfies AssistantMcpDiscoveryRuntimeStepContract;
|
||||
});
|
||||
|
||||
if (adapterStatus === "dry_run_ready") {
|
||||
pushReason(reasonCodes, "runtime_dry_run_ready_without_mcp_execution");
|
||||
} else if (adapterStatus === "blocked") {
|
||||
pushReason(reasonCodes, "runtime_dry_run_blocked_before_mcp_execution");
|
||||
} else {
|
||||
pushReason(reasonCodes, "runtime_dry_run_needs_clarification_before_mcp_execution");
|
||||
}
|
||||
|
||||
return {
|
||||
schema_version: ASSISTANT_MCP_DISCOVERY_RUNTIME_DRY_RUN_SCHEMA_VERSION,
|
||||
policy_owner: "assistantMcpDiscoveryRuntimeAdapter",
|
||||
adapter_status: adapterStatus,
|
||||
planner_status: planner.planner_status,
|
||||
mcp_execution_performed: false,
|
||||
execution_steps: executionSteps,
|
||||
execution_budget: planner.discovery_plan.execution_budget,
|
||||
evidence_gate: {
|
||||
required: true,
|
||||
expected_inputs: [
|
||||
"probe_results",
|
||||
"confirmed_facts",
|
||||
"inferred_facts",
|
||||
"unknown_facts",
|
||||
"source_rows_summary",
|
||||
"query_limitations"
|
||||
],
|
||||
answer_may_use_raw_model_claims: false
|
||||
},
|
||||
user_facing_fallback: fallbackFor(adapterStatus),
|
||||
reason_codes: reasonCodes
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { planAssistantMcpDiscovery } from "../src/services/assistantMcpDiscoveryPlanner";
|
||||
import { buildAssistantMcpDiscoveryRuntimeDryRun } from "../src/services/assistantMcpDiscoveryRuntimeAdapter";
|
||||
|
||||
describe("assistant MCP discovery runtime adapter", () => {
|
||||
it("turns a catalog-compatible value-flow plan into an execution-ready dry-run package", () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "turnover",
|
||||
explicit_entity_candidates: ["SVK"],
|
||||
explicit_date_scope: "2020"
|
||||
}
|
||||
});
|
||||
|
||||
const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner);
|
||||
|
||||
expect(dryRun.adapter_status).toBe("dry_run_ready");
|
||||
expect(dryRun.mcp_execution_performed).toBe(false);
|
||||
expect(dryRun.user_facing_fallback).toBeNull();
|
||||
expect(dryRun.execution_steps.map((step) => step.primitive_id)).toEqual([
|
||||
"resolve_entity_reference",
|
||||
"query_movements",
|
||||
"aggregate_by_axis",
|
||||
"probe_coverage"
|
||||
]);
|
||||
expect(dryRun.execution_steps.every((step) => step.step_status === "ready")).toBe(true);
|
||||
expect(dryRun.execution_steps.every((step) => step.dry_run_only)).toBe(true);
|
||||
expect(dryRun.evidence_gate).toEqual({
|
||||
required: true,
|
||||
expected_inputs: [
|
||||
"probe_results",
|
||||
"confirmed_facts",
|
||||
"inferred_facts",
|
||||
"unknown_facts",
|
||||
"source_rows_summary",
|
||||
"query_limitations"
|
||||
],
|
||||
answer_may_use_raw_model_claims: false
|
||||
});
|
||||
expect(dryRun.reason_codes).toContain("runtime_dry_run_ready_without_mcp_execution");
|
||||
});
|
||||
|
||||
it("keeps execution in clarification state when catalog axes are missing", () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "counterparty_value",
|
||||
asked_action_family: "turnover",
|
||||
explicit_entity_candidates: ["SVK"]
|
||||
}
|
||||
});
|
||||
|
||||
const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner);
|
||||
const movementStep = dryRun.execution_steps.find((step) => step.primitive_id === "query_movements");
|
||||
|
||||
expect(dryRun.adapter_status).toBe("needs_clarification");
|
||||
expect(dryRun.mcp_execution_performed).toBe(false);
|
||||
expect(dryRun.user_facing_fallback).toBe("ask_for_missing_scope_before_mcp_discovery");
|
||||
expect(movementStep?.step_status).toBe("missing_axes");
|
||||
expect(movementStep?.missing_axis_options).toContainEqual(["period", "counterparty"]);
|
||||
expect(dryRun.reason_codes).toContain("runtime_dry_run_needs_clarification_before_mcp_execution");
|
||||
});
|
||||
|
||||
it("keeps blocked planner output blocked before any MCP execution", () => {
|
||||
const planner = planAssistantMcpDiscovery({});
|
||||
planner.planner_status = "blocked";
|
||||
planner.discovery_plan.plan_status = "blocked";
|
||||
planner.catalog_review.review_status = "catalog_blocked";
|
||||
|
||||
const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner);
|
||||
|
||||
expect(dryRun.adapter_status).toBe("blocked");
|
||||
expect(dryRun.mcp_execution_performed).toBe(false);
|
||||
expect(dryRun.user_facing_fallback).toBe("explain_that_runtime_policy_blocked_mcp_discovery");
|
||||
expect(dryRun.execution_steps.every((step) => step.step_status === "blocked")).toBe(true);
|
||||
expect(dryRun.reason_codes).toContain("runtime_dry_run_blocked_before_mcp_execution");
|
||||
});
|
||||
|
||||
it("uses source-summary stop conditions for metadata-only dry runs", () => {
|
||||
const planner = planAssistantMcpDiscovery({
|
||||
turnMeaning: {
|
||||
asked_domain_family: "metadata",
|
||||
asked_action_family: "inspect_catalog"
|
||||
}
|
||||
});
|
||||
|
||||
const dryRun = buildAssistantMcpDiscoveryRuntimeDryRun(planner);
|
||||
|
||||
expect(dryRun.adapter_status).toBe("dry_run_ready");
|
||||
expect(dryRun.execution_steps).toHaveLength(1);
|
||||
expect(dryRun.execution_steps[0]).toMatchObject({
|
||||
primitive_id: "inspect_1c_metadata",
|
||||
evidence_floor: "source_summary",
|
||||
stop_condition: "stop_after_allowed_probe_returns_source_summary_or_limitation"
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue