@wrenai/genbi 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist-server/harness/route/codex-ask.d.ts +1 -1
- package/dist-server/harness/route/codex-ask.js +5 -46
- package/dist-server/harness/route/dispatched.d.ts +20 -0
- package/dist-server/harness/route/dispatched.js +122 -24
- package/dist-server/harness/route/host-query.d.ts +93 -0
- package/dist-server/harness/route/{codex-host-query.js → host-query.js} +120 -54
- package/dist-server/harness/route/route.js +3 -0
- package/dist-server/harness/route/types.d.ts +18 -1
- package/dist-server/harness/route/{codex-wren-proxy.js → wren-host-proxy.js} +6 -5
- package/dist-server/harness/setup/runner.d.ts +15 -3
- package/dist-server/harness/setup/runner.js +234 -72
- package/dist-server/harness/tools/setup-native.d.ts +7 -0
- package/dist-server/harness/tools/setup-native.js +7 -4
- package/dist-server/server/app.js +13 -4
- package/dist-server/server/bin.js +8 -0
- package/dist-server/server/fold.d.ts +9 -8
- package/dist-server/server/fold.js +17 -12
- package/dist-server/server/host-evidence-grounding.d.ts +21 -0
- package/dist-server/server/{codex-local-grounding.js → host-evidence-grounding.js} +15 -14
- package/dist-server/server/native-artifacts.d.ts +93 -4
- package/dist-server/server/native-artifacts.js +153 -4
- package/dist-server/server/turn.js +4 -1
- package/package.json +2 -2
- package/dist-server/harness/route/codex-host-query.d.ts +0 -52
- package/dist-server/server/codex-local-grounding.d.ts +0 -18
- /package/dist-server/harness/route/{codex-wren-proxy.d.ts → wren-host-proxy.d.ts} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BUILD_DASHBOARD_TOOL_NAME, extractEnvelopeFromText, WREN_QUERY_TOOL_NAME } from "../harness/index.js";
|
|
2
|
-
import {
|
|
2
|
+
import { extractJsonCandidateFromText } from "../harness/render/envelope.js";
|
|
3
|
+
import { groundOnHostEvidence } from "./host-evidence-grounding.js";
|
|
3
4
|
/**
|
|
4
5
|
* Canned remediation text for a refusal. `RefusalAgentEvent`/`RefusalResult`
|
|
5
6
|
* carry no remediation hint from the agent runtime — the UI's `fix` field is
|
|
@@ -77,27 +78,31 @@ export function toAnswerOrRefusalEvent(id, result, worklog = []) {
|
|
|
77
78
|
* The one place a subscription answer's `verified` is decided; a `verified`
|
|
78
79
|
* field in model-written text is never kept.
|
|
79
80
|
*
|
|
80
|
-
* - `codex-local`: the model's
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
81
|
+
* - `codex-local` and `agent-sdk`: the model's host query tool (codex:local's
|
|
82
|
+
* `run_sql`, the agent-sdk host MCP's `run_sql`) executes through this turn's
|
|
83
|
+
* host query service, so the answer is grounded against that turn's
|
|
84
|
+
* observations ({@link groundOnHostEvidence}): verified only when every
|
|
85
|
+
* query it cites is one the host ran this turn and that read a table, and
|
|
86
|
+
* then its tables and definitions are the observed ones. Otherwise it stays
|
|
87
|
+
* as written, badged "Unverified" — including any answer whose data came
|
|
88
|
+
* another way (agent-sdk's Bash `wren` stays available and yields no host
|
|
89
|
+
* evidence).
|
|
88
90
|
*
|
|
89
91
|
* Other backends pass through.
|
|
90
92
|
*/
|
|
91
93
|
export function withHostVerification(result, envelope) {
|
|
92
|
-
if (result.backend === "codex-local")
|
|
93
|
-
return
|
|
94
|
-
|
|
94
|
+
if (result.backend === "codex-local" || result.backend === "agent-sdk") {
|
|
95
|
+
return groundOnHostEvidence(envelope, extractJsonCandidateFromText(result.finalText), result.hostObservations ?? []);
|
|
96
|
+
}
|
|
97
|
+
return envelope;
|
|
95
98
|
}
|
|
96
99
|
/** In-process's native in-process tool names that constitute an actual data claim — schema browsing / artifact saving don't count. */
|
|
97
100
|
const DATA_ACCESS_TOOL_NAMES = new Set([
|
|
98
101
|
WREN_QUERY_TOOL_NAME,
|
|
99
102
|
BUILD_DASHBOARD_TOOL_NAME,
|
|
100
103
|
"wren.run_sql",
|
|
104
|
+
// The agent-sdk Ask's host MCP query tool (see `harness/route/dispatched.ts`).
|
|
105
|
+
"mcp__wren_host__run_sql",
|
|
101
106
|
]);
|
|
102
107
|
/**
|
|
103
108
|
* Dispatched never produces those native tool names — the dispatched agent's `answer_query`/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { HostQueryObservation } from "../harness/route/host-query.js";
|
|
2
|
+
import type { RenderEnvelope } from "../harness/render/envelope.js";
|
|
3
|
+
/**
|
|
4
|
+
* Grounds an answer against the queries the host executed for its scope (one codex:local or
|
|
5
|
+
* agent-sdk Ask turn, or one native session), with the component path's predicate
|
|
6
|
+
* (`citedGroundedQuery`: a tool-proven definition that reads at least one table, cited by
|
|
7
|
+
* `query_id`, else by whitespace-insensitive SQL).
|
|
8
|
+
*
|
|
9
|
+
* Verified only when the answer cites at least one query and every citation grounds. The
|
|
10
|
+
* envelope is then rebuilt from the observations alone: one `table` and one `definition` block
|
|
11
|
+
* (carrying the observed `query_id`) per cited query, plus the model's `summary` text as written.
|
|
12
|
+
* A cited result the host saw truncated still grounds the rows it shows; the host then appends
|
|
13
|
+
* a note to `summary`, the only place the render contract carries such text. Anything else stays the model's answer with
|
|
14
|
+
* `verified: false`: no citation, a citation to a query this scope never ran (including one from
|
|
15
|
+
* another turn or session), a query that read no table, or chart / KPI blocks the host cannot rebuild.
|
|
16
|
+
*
|
|
17
|
+
* `raw` is the answer as written, before envelope normalization (see `citations`).
|
|
18
|
+
*/
|
|
19
|
+
export declare function groundOnHostEvidence(envelope: RenderEnvelope, raw: unknown, observations: readonly HostQueryObservation[]): RenderEnvelope;
|
|
20
|
+
/** Host-written, never model-written: a cited table that is only the first rows of its result. */
|
|
21
|
+
export declare function truncationNote(shown: number): string;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { citedGroundedQuery } from "../harness/components/normalize.js";
|
|
2
|
-
import { extractJsonCandidateFromText } from "../harness/render/envelope.js";
|
|
3
2
|
import { namedAnswerQueryId, namedAnswerSql, sqlKey } from "../harness/render/answering-query.js";
|
|
4
3
|
/** Blocks whose values the host cannot rebuild from one observed table; their presence leaves an answer unverified. */
|
|
5
4
|
const UNREBUILDABLE_DATA_BLOCKS = new Set(["chart", "kpi_card"]);
|
|
@@ -7,13 +6,12 @@ function record(value) {
|
|
|
7
6
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
|
|
8
7
|
}
|
|
9
8
|
/**
|
|
10
|
-
* The queries
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* SQL, like the component path.
|
|
9
|
+
* The queries an answer names. A `{blocks}` answer cites through its `definition` blocks; a flat
|
|
10
|
+
* or query-result answer through its own `definition` / top-level fields, read from `raw` (the
|
|
11
|
+
* answer as the model wrote it) because envelope normalization drops a cited `query_id`. A host
|
|
12
|
+
* id wins over SQL, like the component path.
|
|
14
13
|
*/
|
|
15
|
-
function citations(envelope,
|
|
16
|
-
const raw = extractJsonCandidateFromText(finalText);
|
|
14
|
+
function citations(envelope, raw) {
|
|
17
15
|
if (!Array.isArray(record(raw)?.blocks)) {
|
|
18
16
|
const queryId = namedAnswerQueryId(raw);
|
|
19
17
|
if (queryId !== undefined)
|
|
@@ -41,26 +39,29 @@ function citations(envelope, finalText) {
|
|
|
41
39
|
return found;
|
|
42
40
|
}
|
|
43
41
|
/**
|
|
44
|
-
* Grounds
|
|
45
|
-
* with the component path's predicate
|
|
46
|
-
* reads at least one table, cited by
|
|
42
|
+
* Grounds an answer against the queries the host executed for its scope (one codex:local or
|
|
43
|
+
* agent-sdk Ask turn, or one native session), with the component path's predicate
|
|
44
|
+
* (`citedGroundedQuery`: a tool-proven definition that reads at least one table, cited by
|
|
45
|
+
* `query_id`, else by whitespace-insensitive SQL).
|
|
47
46
|
*
|
|
48
47
|
* Verified only when the answer cites at least one query and every citation grounds. The
|
|
49
48
|
* envelope is then rebuilt from the observations alone: one `table` and one `definition` block
|
|
50
49
|
* (carrying the observed `query_id`) per cited query, plus the model's `summary` text as written.
|
|
51
50
|
* A cited result the host saw truncated still grounds the rows it shows; the host then appends
|
|
52
51
|
* a note to `summary`, the only place the render contract carries such text. Anything else stays the model's answer with
|
|
53
|
-
* `verified: false`: no citation, a citation to a query this
|
|
54
|
-
*
|
|
52
|
+
* `verified: false`: no citation, a citation to a query this scope never ran (including one from
|
|
53
|
+
* another turn or session), a query that read no table, or chart / KPI blocks the host cannot rebuild.
|
|
54
|
+
*
|
|
55
|
+
* `raw` is the answer as written, before envelope normalization (see `citations`).
|
|
55
56
|
*/
|
|
56
|
-
export function
|
|
57
|
+
export function groundOnHostEvidence(envelope, raw, observations) {
|
|
57
58
|
const unverified = { ...envelope, verified: false };
|
|
58
59
|
if (observations.length === 0)
|
|
59
60
|
return unverified;
|
|
60
61
|
const blocks = Array.isArray(envelope.blocks) ? envelope.blocks : [];
|
|
61
62
|
if (blocks.some((block) => UNREBUILDABLE_DATA_BLOCKS.has(String(record(block)?.type))))
|
|
62
63
|
return unverified;
|
|
63
|
-
const cited = citations(envelope,
|
|
64
|
+
const cited = citations(envelope, raw);
|
|
64
65
|
if (cited.length === 0)
|
|
65
66
|
return unverified;
|
|
66
67
|
const rebuilt = [];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type NativeSessionRow, type NativeStructuredAnswerRow, type Store } from "./db.js";
|
|
2
2
|
import type { EnrichmentBinding } from "./enrichment.js";
|
|
3
|
+
import type { ComponentAccess } from "../harness/components/broker.js";
|
|
3
4
|
export declare const NATIVE_MCP_CREDENTIAL_ENV_VAR = "WARBLE_MCP_CONNECTION_CREDENTIAL";
|
|
4
5
|
export declare const NATIVE_MCP_TOOL_NAME = "save_dashboard";
|
|
5
6
|
export declare const NATIVE_MCP_PERSIST_ANSWER_TOOL_NAME = "persist_answer";
|
|
7
|
+
export declare const NATIVE_MCP_QUERY_TOOL_NAME = "query";
|
|
6
8
|
export declare const NATIVE_MCP_SERVER_LABEL = "GenBI MCP";
|
|
7
9
|
export declare const NATIVE_MCP_ARTIFACTS_LABEL = "GenBI Artifacts";
|
|
8
10
|
/**
|
|
@@ -98,7 +100,7 @@ export declare const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA: {
|
|
|
98
100
|
readonly properties: {
|
|
99
101
|
readonly verified: {
|
|
100
102
|
readonly type: "boolean";
|
|
101
|
-
readonly description: "Ignored: the host records verified only for an answer it
|
|
103
|
+
readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
|
|
102
104
|
};
|
|
103
105
|
readonly summary: {
|
|
104
106
|
readonly anyOf: readonly [{
|
|
@@ -286,6 +288,15 @@ export declare const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA: {
|
|
|
286
288
|
};
|
|
287
289
|
};
|
|
288
290
|
};
|
|
291
|
+
readonly query_id: {
|
|
292
|
+
readonly type: "string";
|
|
293
|
+
readonly not: {
|
|
294
|
+
readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
|
|
295
|
+
};
|
|
296
|
+
readonly minLength: 1;
|
|
297
|
+
readonly maxLength: 64;
|
|
298
|
+
readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
|
|
299
|
+
};
|
|
289
300
|
};
|
|
290
301
|
}, {
|
|
291
302
|
readonly type: "object";
|
|
@@ -350,7 +361,7 @@ export declare const NATIVE_PERSIST_ANSWER_INPUT_SCHEMA: {
|
|
|
350
361
|
readonly properties: {
|
|
351
362
|
readonly verified: {
|
|
352
363
|
readonly type: "boolean";
|
|
353
|
-
readonly description: "Ignored: the host records verified only for an answer it
|
|
364
|
+
readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
|
|
354
365
|
};
|
|
355
366
|
readonly blocks: {
|
|
356
367
|
readonly type: "array";
|
|
@@ -434,6 +445,15 @@ export declare const NATIVE_PERSIST_ANSWER_INPUT_SCHEMA: {
|
|
|
434
445
|
};
|
|
435
446
|
};
|
|
436
447
|
};
|
|
448
|
+
readonly query_id: {
|
|
449
|
+
readonly type: "string";
|
|
450
|
+
readonly not: {
|
|
451
|
+
readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
|
|
452
|
+
};
|
|
453
|
+
readonly minLength: 1;
|
|
454
|
+
readonly maxLength: 64;
|
|
455
|
+
readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
|
|
456
|
+
};
|
|
437
457
|
};
|
|
438
458
|
}];
|
|
439
459
|
};
|
|
@@ -590,7 +610,7 @@ export declare const NATIVE_SAVE_DASHBOARD_CONTRACT: {
|
|
|
590
610
|
readonly properties: {
|
|
591
611
|
readonly verified: {
|
|
592
612
|
readonly type: "boolean";
|
|
593
|
-
readonly description: "Ignored: the host records verified only for an answer it
|
|
613
|
+
readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
|
|
594
614
|
};
|
|
595
615
|
readonly summary: {
|
|
596
616
|
readonly anyOf: readonly [{
|
|
@@ -778,6 +798,15 @@ export declare const NATIVE_SAVE_DASHBOARD_CONTRACT: {
|
|
|
778
798
|
};
|
|
779
799
|
};
|
|
780
800
|
};
|
|
801
|
+
readonly query_id: {
|
|
802
|
+
readonly type: "string";
|
|
803
|
+
readonly not: {
|
|
804
|
+
readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
|
|
805
|
+
};
|
|
806
|
+
readonly minLength: 1;
|
|
807
|
+
readonly maxLength: 64;
|
|
808
|
+
readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
|
|
809
|
+
};
|
|
781
810
|
};
|
|
782
811
|
}, {
|
|
783
812
|
readonly type: "object";
|
|
@@ -851,7 +880,7 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
|
|
|
851
880
|
readonly properties: {
|
|
852
881
|
readonly verified: {
|
|
853
882
|
readonly type: "boolean";
|
|
854
|
-
readonly description: "Ignored: the host records verified only for an answer it
|
|
883
|
+
readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
|
|
855
884
|
};
|
|
856
885
|
readonly blocks: {
|
|
857
886
|
readonly type: "array";
|
|
@@ -935,6 +964,15 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
|
|
|
935
964
|
};
|
|
936
965
|
};
|
|
937
966
|
};
|
|
967
|
+
readonly query_id: {
|
|
968
|
+
readonly type: "string";
|
|
969
|
+
readonly not: {
|
|
970
|
+
readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
|
|
971
|
+
};
|
|
972
|
+
readonly minLength: 1;
|
|
973
|
+
readonly maxLength: 64;
|
|
974
|
+
readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
|
|
975
|
+
};
|
|
938
976
|
};
|
|
939
977
|
}];
|
|
940
978
|
};
|
|
@@ -956,6 +994,29 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
|
|
|
956
994
|
};
|
|
957
995
|
readonly semanticConstraint: "The envelope contains exactly one typed table block and at most one typed definition block, with no presentation-only fields.";
|
|
958
996
|
};
|
|
997
|
+
/** The session `query` tool: one read-only SQL query run by the host through governed access. */
|
|
998
|
+
export declare const NATIVE_QUERY_INPUT_SCHEMA: {
|
|
999
|
+
readonly type: "object";
|
|
1000
|
+
readonly additionalProperties: false;
|
|
1001
|
+
readonly required: readonly ["sql"];
|
|
1002
|
+
readonly properties: {
|
|
1003
|
+
readonly sql: {
|
|
1004
|
+
readonly type: "string";
|
|
1005
|
+
readonly minLength: 1;
|
|
1006
|
+
readonly description: "One read-only SELECT over the bound semantic models.";
|
|
1007
|
+
};
|
|
1008
|
+
readonly limit: {
|
|
1009
|
+
readonly type: "integer";
|
|
1010
|
+
readonly minimum: 1;
|
|
1011
|
+
readonly description: "Maximum rows to return (default 1000).";
|
|
1012
|
+
};
|
|
1013
|
+
};
|
|
1014
|
+
};
|
|
1015
|
+
export interface NativeQueryResult {
|
|
1016
|
+
readonly isError: boolean;
|
|
1017
|
+
readonly text: string;
|
|
1018
|
+
readonly result?: unknown;
|
|
1019
|
+
}
|
|
959
1020
|
export interface NativeArtifactServiceOptions {
|
|
960
1021
|
readonly store: Store;
|
|
961
1022
|
readonly artifactsRoot: string;
|
|
@@ -964,6 +1025,11 @@ export interface NativeArtifactServiceOptions {
|
|
|
964
1025
|
/** Optional override, accepted only when it is the expected local endpoint. */
|
|
965
1026
|
readonly mcpUrl: string;
|
|
966
1027
|
readonly getBinding: () => EnrichmentBinding | undefined;
|
|
1028
|
+
/**
|
|
1029
|
+
* Opens governed Wren access for one native analysis session's `query` tool, against the
|
|
1030
|
+
* session's bound project. Without it, the tool is not offered.
|
|
1031
|
+
*/
|
|
1032
|
+
readonly openQueryAccess?: (binding: EnrichmentBinding, signal: AbortSignal) => Promise<ComponentAccess>;
|
|
967
1033
|
/** Test-only seam; production writes one contained JSON file atomically. */
|
|
968
1034
|
readonly writeAtomic?: (target: string, contents: string) => void;
|
|
969
1035
|
/** Test-only seam for a persistence outage; production uses the typed Store method. */
|
|
@@ -982,11 +1048,32 @@ export interface NativeArtifactServiceOptions {
|
|
|
982
1048
|
export declare class NativeArtifactService {
|
|
983
1049
|
private readonly options;
|
|
984
1050
|
private readonly credentials;
|
|
1051
|
+
/** One host query recorder per native session: its observations are never shared across sessions. */
|
|
1052
|
+
private readonly queryRecorders;
|
|
1053
|
+
private readonly queryClosing;
|
|
985
1054
|
constructor(options: NativeArtifactServiceOptions);
|
|
986
1055
|
/** Validate the exact local BFF endpoint before advertising or issuing it. */
|
|
987
1056
|
readiness(): NativeMcpReadiness;
|
|
988
1057
|
issue(session: NativeSessionRow, binding: EnrichmentBinding | undefined): NativeMcpDescriptor;
|
|
989
1058
|
revoke(credential: string | undefined): void;
|
|
1059
|
+
/** Whether native analysis sessions are offered the host `query` tool. */
|
|
1060
|
+
queryAvailable(): boolean;
|
|
1061
|
+
/** Resolves once every session query recorder closed so far has released its governed access. */
|
|
1062
|
+
queriesClosed(): Promise<void>;
|
|
1063
|
+
private closeQueries;
|
|
1064
|
+
/**
|
|
1065
|
+
* The session `query` tool: runs one read-only query through governed access against the
|
|
1066
|
+
* session's bound project, records it as this session's observation and returns its rows with
|
|
1067
|
+
* a session-tagged host `query_id` that a later persist_answer or save_dashboard may cite.
|
|
1068
|
+
*/
|
|
1069
|
+
query(credential: string | undefined, input: unknown): Promise<NativeQueryResult>;
|
|
1070
|
+
/**
|
|
1071
|
+
* The host's grounding decision for a model-written envelope: the same envelope rebuilt from
|
|
1072
|
+
* this session's query observations when every query it cites grounds on them (see
|
|
1073
|
+
* `groundOnHostEvidence`), else `undefined`. Only this decision reaches a grounded entry from
|
|
1074
|
+
* the MCP surface.
|
|
1075
|
+
*/
|
|
1076
|
+
private groundedEnvelope;
|
|
990
1077
|
/** Releases opaque credentials when the owning BFF service shuts down. */
|
|
991
1078
|
dispose(): void;
|
|
992
1079
|
hasCredential(credential: string | undefined): boolean;
|
|
@@ -1016,5 +1103,7 @@ export declare class NativeArtifactService {
|
|
|
1016
1103
|
* MCP). The caller must already have checked verified provenance.
|
|
1017
1104
|
*/
|
|
1018
1105
|
saveGrounded(credential: string | undefined, input: unknown): SavedNativeArtifact;
|
|
1106
|
+
/** The credential, session and binding checks every save runs before it reads its payload. */
|
|
1107
|
+
private authorizeSave;
|
|
1019
1108
|
private saveArtifact;
|
|
1020
1109
|
}
|
|
@@ -7,6 +7,8 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
7
7
|
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { newId } from "./db.js";
|
|
10
|
+
import { createHostQueryRecorder } from "../harness/route/host-query.js";
|
|
11
|
+
import { groundOnHostEvidence } from "./host-evidence-grounding.js";
|
|
10
12
|
const MAX_DASHBOARD_BYTES = 256 * 1024;
|
|
11
13
|
const MAX_NAME_LENGTH = 120;
|
|
12
14
|
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
|
@@ -14,6 +16,7 @@ const ANSWER_REFERENCE_PATTERN = /^answer-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{
|
|
|
14
16
|
export const NATIVE_MCP_CREDENTIAL_ENV_VAR = "WARBLE_MCP_CONNECTION_CREDENTIAL";
|
|
15
17
|
export const NATIVE_MCP_TOOL_NAME = "save_dashboard";
|
|
16
18
|
export const NATIVE_MCP_PERSIST_ANSWER_TOOL_NAME = "persist_answer";
|
|
19
|
+
export const NATIVE_MCP_QUERY_TOOL_NAME = "query";
|
|
17
20
|
export const NATIVE_MCP_SERVER_LABEL = "GenBI MCP";
|
|
18
21
|
export const NATIVE_MCP_ARTIFACTS_LABEL = "GenBI Artifacts";
|
|
19
22
|
// Keep this JSON Schema compatible with the ECMA-262 regular-expression
|
|
@@ -53,7 +56,8 @@ const POSITIONAL_ROWS_SCHEMA = {
|
|
|
53
56
|
items: { type: "array", items: SCALAR_SCHEMA },
|
|
54
57
|
};
|
|
55
58
|
/** The exact public MCP shape advertised to native clients and accepted by the host. */
|
|
56
|
-
const VERIFIED_FIELD_SCHEMA = { type: "boolean", description: "Ignored: the host records verified only for an answer it
|
|
59
|
+
const VERIFIED_FIELD_SCHEMA = { type: "boolean", description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps." };
|
|
60
|
+
const QUERY_ID_SCHEMA = { ...SAFE_TEXT_SCHEMA, minLength: 1, maxLength: 64, description: "Optional: the query_id the query tool returned for the query this definition describes." };
|
|
57
61
|
export const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA = {
|
|
58
62
|
type: "object",
|
|
59
63
|
additionalProperties: false,
|
|
@@ -79,7 +83,7 @@ export const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA = {
|
|
|
79
83
|
{ type: "object", additionalProperties: false, required: ["type", "label", "value"], properties: { type: { const: "kpi_card" }, label: SAFE_TEXT_SCHEMA, value: SAFE_TEXT_OR_NUMBER_SCHEMA, unit: NULLABLE_SAFE_TEXT_SCHEMA, delta: { type: ["number", "null"] } } },
|
|
80
84
|
{ type: "object", additionalProperties: false, required: ["type", "columns", "rows"], properties: { type: { const: "table" }, columns: { type: "array", minItems: 1, uniqueItems: true, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, rows: { ...POSITIONAL_ROWS_SCHEMA, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.table.positionalRows } } },
|
|
81
85
|
{ type: "object", additionalProperties: false, required: ["type", "chart_type", "x", "series", "rows"], properties: { type: { const: "chart" }, chart_type: { enum: ["bar", "line", "pie", "area", "scatter"] }, x: { ...SAFE_TEXT_SCHEMA, minLength: 1, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.x }, series: { type: "array", minItems: 1, uniqueItems: true, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.series, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, rows: { ...POSITIONAL_ROWS_SCHEMA, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.positionalRows } } },
|
|
82
|
-
{ type: "object", additionalProperties: false, required: ["type", "sql", "source_tables", "filters"], properties: { type: { const: "definition" }, sql: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, source_tables: { type: "array", minItems: 1, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, filters: { type: "array", items: SAFE_TEXT_SCHEMA } } },
|
|
86
|
+
{ type: "object", additionalProperties: false, required: ["type", "sql", "source_tables", "filters"], properties: { type: { const: "definition" }, sql: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, source_tables: { type: "array", minItems: 1, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, filters: { type: "array", items: SAFE_TEXT_SCHEMA }, query_id: QUERY_ID_SCHEMA } },
|
|
83
87
|
{ type: "object", additionalProperties: false, required: ["type", "text"], properties: { type: { const: "narrative" }, text: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, title: NULLABLE_SAFE_TEXT_SCHEMA } },
|
|
84
88
|
] },
|
|
85
89
|
},
|
|
@@ -163,6 +167,30 @@ export const NATIVE_PERSIST_ANSWER_CONTRACT = {
|
|
|
163
167
|
inputSchema: NATIVE_PERSIST_ANSWER_INPUT_SCHEMA,
|
|
164
168
|
semanticConstraint: "The envelope contains exactly one typed table block and at most one typed definition block, with no presentation-only fields.",
|
|
165
169
|
};
|
|
170
|
+
/** The session `query` tool: one read-only SQL query run by the host through governed access. */
|
|
171
|
+
export const NATIVE_QUERY_INPUT_SCHEMA = {
|
|
172
|
+
type: "object",
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
required: ["sql"],
|
|
175
|
+
properties: {
|
|
176
|
+
sql: { type: "string", minLength: 1, description: "One read-only SELECT over the bound semantic models." },
|
|
177
|
+
limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default 1000)." },
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
/** The sentence the model sees per host error class; nothing from the governed transport is forwarded. */
|
|
181
|
+
const NATIVE_QUERY_ERROR_TEXT = {
|
|
182
|
+
invalid_request: "The request did not match the query contract: pass a non-empty sql string and an optional positive integer limit.",
|
|
183
|
+
model_not_found: "The query references a table that is not a model in the bound semantic context; query only the models it defines.",
|
|
184
|
+
policy_rejected: "The read-only analytical policy rejected the query: use one SELECT that reads at least one bound model.",
|
|
185
|
+
invalid_sql: "The SQL could not be parsed or planned against the semantic context.",
|
|
186
|
+
execution_failed: "The data source rejected the query at execution time, for example an unknown column or a type mismatch.",
|
|
187
|
+
timeout: "The query exceeded the statement time limit.",
|
|
188
|
+
datasource_unavailable: "The bound data source could not be reached.",
|
|
189
|
+
result_too_large: "The result exceeded the governed byte limit; narrow the query.",
|
|
190
|
+
budget_exhausted: "This session's query budget is exhausted; answer from the results you already have.",
|
|
191
|
+
internal: "The governed query failed for an unclassified reason.",
|
|
192
|
+
transport: "The host query service is unavailable.",
|
|
193
|
+
};
|
|
166
194
|
function rejectsExecutableText(value) {
|
|
167
195
|
if (typeof value === "string")
|
|
168
196
|
return /<\s*\/?(?:script|html)\b|javascript\s*:/i.test(value);
|
|
@@ -202,7 +230,7 @@ function validBlock(block) {
|
|
|
202
230
|
// Existing object rows remain exactly bound to x + series.
|
|
203
231
|
: isRecord(row) && scalar(row[x]) && series.every((item) => scalar(row[item])) && Object.keys(row).every((key) => key === x || series.includes(key)));
|
|
204
232
|
case "definition":
|
|
205
|
-
return onlyKeys(block, ["type", "sql", "source_tables", "filters"]) && typeof block.sql === "string" && block.sql.length > 0 && Array.isArray(block.source_tables) && block.source_tables.length > 0 && block.source_tables.every((source) => typeof source === "string" && source.length > 0) && Array.isArray(block.filters) && block.filters.every((filter) => typeof filter === "string");
|
|
233
|
+
return (onlyKeys(block, ["type", "sql", "source_tables", "filters"]) || (onlyKeys(block, ["type", "sql", "source_tables", "filters", "query_id"]) && typeof block.query_id === "string" && block.query_id.length > 0 && block.query_id.length <= 64)) && typeof block.sql === "string" && block.sql.length > 0 && Array.isArray(block.source_tables) && block.source_tables.length > 0 && block.source_tables.every((source) => typeof source === "string" && source.length > 0) && Array.isArray(block.filters) && block.filters.every((filter) => typeof filter === "string");
|
|
206
234
|
case "narrative":
|
|
207
235
|
return (onlyKeys(block, ["type", "text"]) || onlyKeys(block, ["type", "text", "title"])) && typeof block.text === "string" && block.text.length > 0 && (block.title === undefined || block.title === null || typeof block.title === "string");
|
|
208
236
|
default:
|
|
@@ -259,6 +287,24 @@ function validateInput(value) {
|
|
|
259
287
|
throw new NativeArtifactError("invalid Save to Artifacts payload: answer_selection must be latest");
|
|
260
288
|
return { version: "1", name: value.name, answer_selection: "latest", idempotency_key: value.idempotency_key };
|
|
261
289
|
}
|
|
290
|
+
function isValidSaveInput(value) {
|
|
291
|
+
try {
|
|
292
|
+
validateInput(value);
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function isValidPersistedStructuredAnswer(value) {
|
|
300
|
+
try {
|
|
301
|
+
validatePersistedStructuredAnswer(value);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
262
308
|
function validatePersistedStructuredAnswer(value) {
|
|
263
309
|
if (!isRecord(value) || !onlyKeys(value, ["version", "idempotency_key", "envelope"]))
|
|
264
310
|
throw new NativeArtifactError("invalid structured answer payload: expected exactly version, idempotency_key, and envelope");
|
|
@@ -305,6 +351,9 @@ function validateStoredStructuredAnswer(source, nativeSessionId) {
|
|
|
305
351
|
export class NativeArtifactService {
|
|
306
352
|
options;
|
|
307
353
|
credentials = new Map();
|
|
354
|
+
/** One host query recorder per native session: its observations are never shared across sessions. */
|
|
355
|
+
queryRecorders = new Map();
|
|
356
|
+
queryClosing = new Set();
|
|
308
357
|
constructor(options) {
|
|
309
358
|
this.options = options;
|
|
310
359
|
}
|
|
@@ -336,7 +385,78 @@ export class NativeArtifactService {
|
|
|
336
385
|
revoke(credential) {
|
|
337
386
|
if (!credential)
|
|
338
387
|
return;
|
|
388
|
+
const context = this.credentials.get(credential);
|
|
339
389
|
this.credentials.delete(credential);
|
|
390
|
+
// A session can hold more than one credential (a Codex session's terminal and its component
|
|
391
|
+
// runtime each get one). Its query recorder lives until the session ends: the last credential
|
|
392
|
+
// for the session is revoked, which every session end and `dispose()` do.
|
|
393
|
+
if (context && ![...this.credentials.values()].some((other) => other.session.id === context.session.id))
|
|
394
|
+
this.closeQueries(context.session.id);
|
|
395
|
+
}
|
|
396
|
+
/** Whether native analysis sessions are offered the host `query` tool. */
|
|
397
|
+
queryAvailable() { return this.options.openQueryAccess !== undefined; }
|
|
398
|
+
/** Resolves once every session query recorder closed so far has released its governed access. */
|
|
399
|
+
async queriesClosed() { await Promise.allSettled([...this.queryClosing]); }
|
|
400
|
+
closeQueries(sessionId) {
|
|
401
|
+
const recorder = this.queryRecorders.get(sessionId);
|
|
402
|
+
if (!recorder)
|
|
403
|
+
return;
|
|
404
|
+
this.queryRecorders.delete(sessionId);
|
|
405
|
+
const closing = recorder.close();
|
|
406
|
+
this.queryClosing.add(closing);
|
|
407
|
+
void closing.finally(() => this.queryClosing.delete(closing));
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The session `query` tool: runs one read-only query through governed access against the
|
|
411
|
+
* session's bound project, records it as this session's observation and returns its rows with
|
|
412
|
+
* a session-tagged host `query_id` that a later persist_answer or save_dashboard may cite.
|
|
413
|
+
*/
|
|
414
|
+
async query(credential, input) {
|
|
415
|
+
const session = this.authorize(credential);
|
|
416
|
+
const binding = this.credentials.get(credential)?.binding;
|
|
417
|
+
const openQueryAccess = this.options.openQueryAccess;
|
|
418
|
+
if (session.purpose !== "analysis" || !binding || !openQueryAccess)
|
|
419
|
+
throw new NativeArtifactError("the query tool is unavailable for this native session", 409);
|
|
420
|
+
const args = isRecord(input) ? input : {};
|
|
421
|
+
if (!isRecord(input) || !Object.keys(input).every((key) => key === "sql" || key === "limit"))
|
|
422
|
+
return { isError: true, text: NATIVE_QUERY_ERROR_TEXT.invalid_request };
|
|
423
|
+
let recorder = this.queryRecorders.get(session.id);
|
|
424
|
+
if (!recorder) {
|
|
425
|
+
recorder = createHostQueryRecorder({ openAccess: (signal) => openQueryAccess(binding, signal) });
|
|
426
|
+
this.queryRecorders.set(session.id, recorder);
|
|
427
|
+
}
|
|
428
|
+
const outcome = await recorder.execute(args.sql, args.limit);
|
|
429
|
+
if ("error" in outcome)
|
|
430
|
+
return { isError: true, text: `Governed Wren query failed [${outcome.error}]: ${NATIVE_QUERY_ERROR_TEXT[outcome.error] ?? NATIVE_QUERY_ERROR_TEXT.internal}` };
|
|
431
|
+
return { isError: false, text: JSON.stringify(outcome.result), result: outcome.result };
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* The host's grounding decision for a model-written envelope: the same envelope rebuilt from
|
|
435
|
+
* this session's query observations when every query it cites grounds on them (see
|
|
436
|
+
* `groundOnHostEvidence`), else `undefined`. Only this decision reaches a grounded entry from
|
|
437
|
+
* the MCP surface.
|
|
438
|
+
*/
|
|
439
|
+
groundedEnvelope(credential, envelope) {
|
|
440
|
+
const context = credential === undefined ? undefined : this.credentials.get(credential);
|
|
441
|
+
const recorder = context ? this.queryRecorders.get(context.session.id) : undefined;
|
|
442
|
+
if (!recorder || !isRecord(envelope))
|
|
443
|
+
return undefined;
|
|
444
|
+
// The host rebuilds only table and definition blocks. Any other block (narrative, chart, KPI)
|
|
445
|
+
// or an `estimate` flag would be dropped by the rebuild, so such an envelope is never grounded:
|
|
446
|
+
// it is kept intact and stored unverified rather than losing content silently.
|
|
447
|
+
if (envelope.estimate !== undefined && envelope.estimate !== null)
|
|
448
|
+
return undefined;
|
|
449
|
+
if (!Array.isArray(envelope.blocks) || envelope.blocks.some((block) => !isRecord(block) || (block.type !== "table" && block.type !== "definition")))
|
|
450
|
+
return undefined;
|
|
451
|
+
const grounded = groundOnHostEvidence(envelope, envelope, recorder.observations());
|
|
452
|
+
if (grounded.verified !== true)
|
|
453
|
+
return undefined;
|
|
454
|
+
// The rebuild emits one table and one definition per cited query. If it has fewer of either
|
|
455
|
+
// than the envelope did (an extra uncited table, say), content would be lost: keep it intact.
|
|
456
|
+
const count = (value, type) => Array.isArray(value.blocks) ? value.blocks.filter((block) => isRecord(block) && block.type === type).length : 0;
|
|
457
|
+
if (count(grounded, "table") !== count(envelope, "table") || count(grounded, "definition") !== count(envelope, "definition"))
|
|
458
|
+
return undefined;
|
|
459
|
+
return grounded;
|
|
340
460
|
}
|
|
341
461
|
/** Releases opaque credentials when the owning BFF service shuts down. */
|
|
342
462
|
dispose() { for (const credential of [...this.credentials.keys()])
|
|
@@ -386,6 +506,20 @@ export class NativeArtifactService {
|
|
|
386
506
|
* a later follow-up can only reference this stored result, never recompute.
|
|
387
507
|
*/
|
|
388
508
|
persistAnswer(credential, input) {
|
|
509
|
+
// Authorize before validating, as `persistStructured` does: a bad credential is a 401 and a
|
|
510
|
+
// non-analysis session a 409 whatever the payload.
|
|
511
|
+
const session = this.authorize(credential);
|
|
512
|
+
if (session.purpose !== "analysis")
|
|
513
|
+
throw new NativeArtifactError("structured answer persistence is unavailable for this native session", 409);
|
|
514
|
+
const payload = validatePersistedStructuredAnswer(input);
|
|
515
|
+
const grounded = this.groundedEnvelope(credential, payload.envelope);
|
|
516
|
+
if (grounded) {
|
|
517
|
+
const candidate = { ...payload, envelope: grounded };
|
|
518
|
+
// A rebuilt envelope the persist contract cannot hold (a truncation note needs a summary,
|
|
519
|
+
// which this contract has no field for) is persisted unverified, as written.
|
|
520
|
+
if (isValidPersistedStructuredAnswer(candidate))
|
|
521
|
+
return this.persistStructured(credential, candidate, true);
|
|
522
|
+
}
|
|
389
523
|
return this.persistStructured(credential, input, false);
|
|
390
524
|
}
|
|
391
525
|
/**
|
|
@@ -428,6 +562,16 @@ export class NativeArtifactService {
|
|
|
428
562
|
}
|
|
429
563
|
}
|
|
430
564
|
save(credential, input) {
|
|
565
|
+
// Authorize before validating, as `saveArtifact` does.
|
|
566
|
+
this.authorizeSave(credential);
|
|
567
|
+
const payload = validateInput(input);
|
|
568
|
+
// Only an envelope save is grounded here; a save by reference inherits the retained answer's provenance.
|
|
569
|
+
const grounded = "envelope" in payload ? this.groundedEnvelope(credential, payload.envelope) : undefined;
|
|
570
|
+
if (grounded) {
|
|
571
|
+
const candidate = { ...payload, envelope: grounded };
|
|
572
|
+
if (isValidSaveInput(candidate))
|
|
573
|
+
return this.saveArtifact(credential, candidate, true);
|
|
574
|
+
}
|
|
431
575
|
return this.saveArtifact(credential, input, false);
|
|
432
576
|
}
|
|
433
577
|
/**
|
|
@@ -437,7 +581,8 @@ export class NativeArtifactService {
|
|
|
437
581
|
saveGrounded(credential, input) {
|
|
438
582
|
return this.saveArtifact(credential, input, true);
|
|
439
583
|
}
|
|
440
|
-
|
|
584
|
+
/** The credential, session and binding checks every save runs before it reads its payload. */
|
|
585
|
+
authorizeSave(credential) {
|
|
441
586
|
const context = credential === undefined ? undefined : this.credentials.get(credential);
|
|
442
587
|
if (!context)
|
|
443
588
|
throw new NativeArtifactError("GenBI MCP bearer credential is invalid. Restart this native session to refresh its GenBI MCP connection.", 401);
|
|
@@ -447,6 +592,10 @@ export class NativeArtifactService {
|
|
|
447
592
|
this.revoke(credential);
|
|
448
593
|
throw new NativeArtifactError("GenBI MCP session binding is stale. Start a new native session.", 409);
|
|
449
594
|
}
|
|
595
|
+
return { session, binding };
|
|
596
|
+
}
|
|
597
|
+
saveArtifact(credential, input, grounded) {
|
|
598
|
+
const { session, binding } = this.authorizeSave(credential);
|
|
450
599
|
const payload = validateInput(input);
|
|
451
600
|
const source = "answer_ref" in payload
|
|
452
601
|
? this.options.store.getNativeStructuredAnswer(payload.answer_ref)
|
|
@@ -604,7 +604,10 @@ async function executeSetupTurn(deps, session, turn, emit, signal) {
|
|
|
604
604
|
? undefined
|
|
605
605
|
: deps.store.getSetupContextLifecycleEvidence(session.id, identityFingerprint)?.completed;
|
|
606
606
|
if (identityFingerprint !== undefined) {
|
|
607
|
-
const recorded = recordedContextLifecyclePrefix(worklog, prior ?? "none"
|
|
607
|
+
const recorded = recordedContextLifecyclePrefix(worklog, prior ?? "none", {
|
|
608
|
+
root: workspaceRoot,
|
|
609
|
+
projectDir: path.join(workspaceRoot, form.projectName),
|
|
610
|
+
});
|
|
608
611
|
// A build command is not retained by itself: only a successful terminal
|
|
609
612
|
// parse has independently checked target/mdl.json. Discovery/validate
|
|
610
613
|
// remain useful across an interrupted turn, but build includes artifact
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wrenai/genbi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Wren GenBI — the web UI for WrenAI: ask questions of your data and get dashboards back",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@warble/cli": "0.17.0",
|
|
40
40
|
"@warble/codex-local": "0.17.0",
|
|
41
41
|
"@warble/ir-spec": "0.9.0",
|
|
42
|
-
"@wrenai/context-loader": "0.1.
|
|
42
|
+
"@wrenai/context-loader": "0.1.4",
|
|
43
43
|
"@xterm/addon-fit": "^0.11.0",
|
|
44
44
|
"@xterm/xterm": "^6.0.0",
|
|
45
45
|
"ai": "~7.0.28",
|