@retinue/agentkit 0.1.0 → 0.2.0
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 +59 -277
- package/dist/adapters/embeddings/openai.d.ts +45 -0
- package/dist/adapters/embeddings/openai.js +109 -0
- package/dist/agents/agent.d.ts +22 -1
- package/dist/agents/agent.js +97 -11
- package/dist/agents/engine.d.ts +28 -0
- package/dist/agents/engine.js +194 -8
- package/dist/capabilities/index.d.ts +5 -1
- package/dist/capabilities/index.js +23 -0
- package/dist/capabilities/runtime.d.ts +8 -0
- package/dist/core/budget.d.ts +55 -0
- package/dist/core/budget.js +56 -0
- package/dist/core/content-parts.d.ts +8 -0
- package/dist/core/events.d.ts +68 -2
- package/dist/core/events.js +2 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/documents/index.d.ts +14 -0
- package/dist/documents/parsers/text.d.ts +16 -0
- package/dist/documents/parsers/text.js +54 -2
- package/dist/entries/guardrails.d.ts +14 -0
- package/dist/entries/guardrails.js +14 -0
- package/dist/entries/knowledge.d.ts +9 -0
- package/dist/entries/knowledge.js +8 -0
- package/dist/graphql/resolvers.d.ts +4 -0
- package/dist/graphql/resolvers.js +6 -0
- package/dist/graphql/schema.d.ts +1 -1
- package/dist/graphql/schema.js +44 -0
- package/dist/guardrails/index.d.ts +115 -0
- package/dist/guardrails/index.js +108 -0
- package/dist/guardrails/moderation.d.ts +53 -0
- package/dist/guardrails/moderation.js +75 -0
- package/dist/guardrails/pii.d.ts +75 -0
- package/dist/guardrails/pii.js +193 -0
- package/dist/knowledge/index.d.ts +1 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/navigate.d.ts +89 -0
- package/dist/knowledge/navigate.js +107 -0
- package/dist/knowledge/retrieval.d.ts +73 -5
- package/dist/knowledge/retrieval.js +82 -28
- package/dist/models/streaming.d.ts +22 -1
- package/dist/models/streaming.js +5 -1
- package/dist/security/checklist.js +9 -0
- package/dist/security/findings.js +18 -9
- package/dist/skills/catalogue.d.ts +49 -0
- package/dist/skills/catalogue.js +61 -0
- package/dist/skills/index.d.ts +1 -0
- package/dist/skills/index.js +1 -0
- package/dist/telemetry/spans.js +12 -0
- package/dist/toolkit/files.d.ts +125 -0
- package/dist/toolkit/files.js +320 -0
- package/dist/toolkit/index.d.ts +4 -0
- package/dist/toolkit/index.js +2 -0
- package/dist/toolkit/sandbox.d.ts +119 -0
- package/dist/toolkit/sandbox.js +239 -0
- package/dist/toolkit/web.d.ts +13 -0
- package/dist/toolkit/web.js +7 -1
- package/dist/tools/budget.d.ts +28 -0
- package/dist/tools/budget.js +35 -0
- package/dist/tools/credentials.d.ts +57 -0
- package/dist/tools/credentials.js +54 -0
- package/dist/tools/define.d.ts +31 -0
- package/dist/tools/define.js +23 -0
- package/dist/tools/find.d.ts +109 -0
- package/dist/tools/find.js +210 -0
- package/dist/tools/index.d.ts +14 -2
- package/dist/tools/index.js +4 -0
- package/dist/tools/library/fs.d.ts +24 -0
- package/dist/tools/library/fs.js +102 -0
- package/dist/tools/library/index.d.ts +29 -2
- package/dist/tools/library/index.js +40 -0
- package/dist/tools/library/shell.d.ts +45 -0
- package/dist/tools/library/shell.js +70 -0
- package/dist/tools/meta-tools.js +8 -0
- package/dist/tools/registry.d.ts +113 -0
- package/dist/tools/registry.js +180 -4
- package/package.json +5 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardrails — REQ-046 (#205), task #211.
|
|
3
|
+
*
|
|
4
|
+
* A seam for checks a deployment needs and this runtime does not ship: PII redaction, moderation, a topic
|
|
5
|
+
* restriction, an output schema. Without it, a deployment that needs any of those has to edit the engine.
|
|
6
|
+
*
|
|
7
|
+
* The injection half of "guardrails" is already built and lives elsewhere (`security/prompt-safety.ts`), and it
|
|
8
|
+
* is deliberately *not* a guardrail in this sense: containment is structural — untrusted content is wrapped in a
|
|
9
|
+
* nonce-delimited envelope whether or not anything recognises an attack — whereas everything here is
|
|
10
|
+
* *inspection*, which can only act on what it detects. Conflating the two would invite someone to switch off
|
|
11
|
+
* containment because a detector is present.
|
|
12
|
+
*
|
|
13
|
+
* ## Three decisions that make this worth having
|
|
14
|
+
*
|
|
15
|
+
* **Tool calls are outputs.** A guardrail that inspects only the final message can be walked straight past by
|
|
16
|
+
* putting the data in a tool argument. Checking prose and not arguments is checking the boring half, so
|
|
17
|
+
* `GuardrailOutput` is a discriminated union of a message *and* a tool call, and the tool-call case is enforced
|
|
18
|
+
* at the one choke point every call goes through.
|
|
19
|
+
*
|
|
20
|
+
* **Fail closed.** A guardrail that throws refuses the turn, attributed to the guardrail that threw. The
|
|
21
|
+
* opposite default is how a guardrail silently stops guarding the day its dependency times out — and the run
|
|
22
|
+
* looks entirely normal afterwards, which is the property that makes it dangerous.
|
|
23
|
+
*
|
|
24
|
+
* **Every verdict is recorded, and never the value.** A redaction that leaves no trace is indistinguishable from
|
|
25
|
+
* the model never having been told, which makes an incident unreconstructable. So a record names *what* was
|
|
26
|
+
* redacted — the field, the entity type — and never what it contained, or the audit trail becomes the leak.
|
|
27
|
+
*/
|
|
28
|
+
/** What a guardrail may conclude. */
|
|
29
|
+
export const GUARDRAIL_OUTCOMES = ["pass", "redacted", "refused"];
|
|
30
|
+
const REFUSED_BY_THROW = "guardrail_failed";
|
|
31
|
+
const subjectOf = (value) => {
|
|
32
|
+
if (typeof value === "object" && value !== null && "kind" in value) {
|
|
33
|
+
const kind = value.kind;
|
|
34
|
+
return kind === "tool-call" || kind === "tool-result" ? kind : "message";
|
|
35
|
+
}
|
|
36
|
+
return "input";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Run a list in declared order, threading the value through.
|
|
40
|
+
*
|
|
41
|
+
* Threading is what makes two redacting guardrails compose: the second inspects what the first produced, so one
|
|
42
|
+
* cannot undo the other by inspecting the original and returning its own edit of it. Order is the caller's
|
|
43
|
+
* declaration and is never sorted here — a guardrail set whose order depends on object key iteration is a set
|
|
44
|
+
* whose behaviour changes when someone reformats the config.
|
|
45
|
+
*
|
|
46
|
+
* A refusal short-circuits: the remaining guardrails are not consulted, because the turn is over and running
|
|
47
|
+
* them would spend money to annotate a decision already taken.
|
|
48
|
+
*
|
|
49
|
+
* Two exported entry points over one core rather than a `hook: "inspectInput" | "inspectOutput"` parameter. The
|
|
50
|
+
* parameterised version does not typecheck — indexing a union of two method signatures gives a function callable
|
|
51
|
+
* with neither argument type — and the `never` cast that silences it would have erased exactly the distinction
|
|
52
|
+
* the two subjects exist to keep.
|
|
53
|
+
*/
|
|
54
|
+
const applyEach = async (guardrails, select, value, context) => {
|
|
55
|
+
const records = [];
|
|
56
|
+
let current = value;
|
|
57
|
+
for (const guardrail of guardrails) {
|
|
58
|
+
const inspect = select(guardrail);
|
|
59
|
+
if (inspect === undefined)
|
|
60
|
+
continue;
|
|
61
|
+
let verdict;
|
|
62
|
+
try {
|
|
63
|
+
verdict = await inspect(current, context);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
// Fail closed, and say which one. A guardrail whose dependency timed out must not become a guardrail that
|
|
67
|
+
// passed everything: the whole point is that its absence is visible.
|
|
68
|
+
records.push({
|
|
69
|
+
guardrail: guardrail.name,
|
|
70
|
+
subject: subjectOf(current),
|
|
71
|
+
outcome: "refused",
|
|
72
|
+
code: REFUSED_BY_THROW,
|
|
73
|
+
threw: true,
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
outcome: "refused",
|
|
77
|
+
by: guardrail.name,
|
|
78
|
+
code: REFUSED_BY_THROW,
|
|
79
|
+
message: `guardrail ${guardrail.name} could not complete: ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
records,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (verdict.kind === "pass") {
|
|
84
|
+
records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "pass" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (verdict.kind === "redacted") {
|
|
88
|
+
records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "redacted", what: verdict.what });
|
|
89
|
+
current = verdict.value;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
records.push({ guardrail: guardrail.name, subject: subjectOf(current), outcome: "refused", code: verdict.code });
|
|
93
|
+
return { outcome: "refused", by: guardrail.name, code: verdict.code, message: verdict.message, records };
|
|
94
|
+
}
|
|
95
|
+
return { outcome: "allowed", value: current, records };
|
|
96
|
+
};
|
|
97
|
+
/** Before the model sees the turn. */
|
|
98
|
+
export const applyInputGuardrails = (guardrails, input, context) => applyEach(guardrails, (g) => (g.inspectInput ? (v, c) => g.inspectInput(v, c) : undefined), input, context);
|
|
99
|
+
/** Before anything leaves the model — a message *or* a tool call. */
|
|
100
|
+
export const applyOutputGuardrails = (guardrails, output, context) => applyEach(guardrails, (g) => (g.inspectOutput ? (v, c) => g.inspectOutput(v, c) : undefined), output, context);
|
|
101
|
+
/**
|
|
102
|
+
* Whether a record could carry an inspected value — used by the test that asserts it never does.
|
|
103
|
+
*
|
|
104
|
+
* Here rather than in the test file because it states the invariant next to the type it constrains: a record has
|
|
105
|
+
* a fixed shape, and adding a field that holds content is the change this is meant to make somebody notice.
|
|
106
|
+
*/
|
|
107
|
+
export const recordCarriesOnlyMetadata = (record) => Object.keys(record).every((key) => ["guardrail", "subject", "outcome", "what", "code", "threw"].includes(key));
|
|
108
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moderation, as an adapter — REQ-046 (#205), task #212, AC-4.
|
|
3
|
+
*
|
|
4
|
+
* **Off unless declared, and it takes the classifier rather than choosing one.** That is the whole design
|
|
5
|
+
* decision, and it is a cost decision rather than a technical one: a model call on every turn doubles the
|
|
6
|
+
* latency floor and adds a per-turn charge, and whether that is worth it depends on what a deployment is for.
|
|
7
|
+
* A runtime that imposed it would be spending somebody else's money on a policy they did not choose.
|
|
8
|
+
*
|
|
9
|
+
* So `classify` is supplied by the host. OpenAI's moderation endpoint is one implementation; a local classifier,
|
|
10
|
+
* a keyword list, or a shared service are others. None of them is a dependency of this package.
|
|
11
|
+
*
|
|
12
|
+
* ## The cost, stated
|
|
13
|
+
*
|
|
14
|
+
* One classifier call per inspected subject. With `subjects: ["input", "message"]` — the default — that is two
|
|
15
|
+
* calls per turn, in series with the model rather than parallel to it, because a turn that has already been
|
|
16
|
+
* answered cannot be un-answered. Add roughly the classifier's own latency twice to every turn.
|
|
17
|
+
*
|
|
18
|
+
* Reducing that is a real option and is why `subjects` is configurable: inspecting only `input` halves the cost
|
|
19
|
+
* and leaves generated content unchecked, which is the right trade for an internal tool and the wrong one for
|
|
20
|
+
* anything public.
|
|
21
|
+
*
|
|
22
|
+
* ## Failure is handled by the port, not here
|
|
23
|
+
*
|
|
24
|
+
* A classifier that times out throws, and `applyInputGuardrails` turns a throw into a refusal attributed to this
|
|
25
|
+
* guardrail. That is deliberate and it is the expensive-looking choice: a moderation outage stops turns. The
|
|
26
|
+
* alternative is a moderation outage that silently stops moderating, which is the one nobody notices.
|
|
27
|
+
*/
|
|
28
|
+
import type { Guardrail } from "./index.js";
|
|
29
|
+
export type ModerationResult = {
|
|
30
|
+
readonly flagged: boolean;
|
|
31
|
+
/** Category names from the classifier. Used in the refusal message and the record; never the content. */
|
|
32
|
+
readonly categories?: readonly string[];
|
|
33
|
+
};
|
|
34
|
+
/** What the host supplies. Throwing is a refusal — see the module comment. */
|
|
35
|
+
export type ModerationClassifier = (text: string) => Promise<ModerationResult> | ModerationResult;
|
|
36
|
+
/** Which subjects to spend a classifier call on. */
|
|
37
|
+
export declare const MODERATION_SUBJECTS: readonly ["input", "message", "tool-result"];
|
|
38
|
+
export type ModerationSubject = (typeof MODERATION_SUBJECTS)[number];
|
|
39
|
+
export type ModerationOptions = {
|
|
40
|
+
readonly classify: ModerationClassifier;
|
|
41
|
+
/**
|
|
42
|
+
* Defaults to `["input", "message"]` — two calls per turn.
|
|
43
|
+
*
|
|
44
|
+
* `tool-result` is off by default because a tool result is usually structured data rather than prose, and
|
|
45
|
+
* classifying JSON produces confident nonsense. Turn it on when tools return free text somebody will read.
|
|
46
|
+
*/
|
|
47
|
+
readonly subjects?: readonly ModerationSubject[];
|
|
48
|
+
/** Minimum text length worth a call. Defaults to 1: a classifier call on an empty string is pure cost. */
|
|
49
|
+
readonly minLength?: number;
|
|
50
|
+
readonly name?: string;
|
|
51
|
+
};
|
|
52
|
+
export declare const createModerationGuardrail: (options: ModerationOptions) => Guardrail;
|
|
53
|
+
//# sourceMappingURL=moderation.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Moderation, as an adapter — REQ-046 (#205), task #212, AC-4.
|
|
3
|
+
*
|
|
4
|
+
* **Off unless declared, and it takes the classifier rather than choosing one.** That is the whole design
|
|
5
|
+
* decision, and it is a cost decision rather than a technical one: a model call on every turn doubles the
|
|
6
|
+
* latency floor and adds a per-turn charge, and whether that is worth it depends on what a deployment is for.
|
|
7
|
+
* A runtime that imposed it would be spending somebody else's money on a policy they did not choose.
|
|
8
|
+
*
|
|
9
|
+
* So `classify` is supplied by the host. OpenAI's moderation endpoint is one implementation; a local classifier,
|
|
10
|
+
* a keyword list, or a shared service are others. None of them is a dependency of this package.
|
|
11
|
+
*
|
|
12
|
+
* ## The cost, stated
|
|
13
|
+
*
|
|
14
|
+
* One classifier call per inspected subject. With `subjects: ["input", "message"]` — the default — that is two
|
|
15
|
+
* calls per turn, in series with the model rather than parallel to it, because a turn that has already been
|
|
16
|
+
* answered cannot be un-answered. Add roughly the classifier's own latency twice to every turn.
|
|
17
|
+
*
|
|
18
|
+
* Reducing that is a real option and is why `subjects` is configurable: inspecting only `input` halves the cost
|
|
19
|
+
* and leaves generated content unchecked, which is the right trade for an internal tool and the wrong one for
|
|
20
|
+
* anything public.
|
|
21
|
+
*
|
|
22
|
+
* ## Failure is handled by the port, not here
|
|
23
|
+
*
|
|
24
|
+
* A classifier that times out throws, and `applyInputGuardrails` turns a throw into a refusal attributed to this
|
|
25
|
+
* guardrail. That is deliberate and it is the expensive-looking choice: a moderation outage stops turns. The
|
|
26
|
+
* alternative is a moderation outage that silently stops moderating, which is the one nobody notices.
|
|
27
|
+
*/
|
|
28
|
+
/** Which subjects to spend a classifier call on. */
|
|
29
|
+
export const MODERATION_SUBJECTS = ["input", "message", "tool-result"];
|
|
30
|
+
const textOf = (output) => {
|
|
31
|
+
if (output.kind === "message")
|
|
32
|
+
return output.text;
|
|
33
|
+
if (output.kind === "tool-result")
|
|
34
|
+
return typeof output.output === "string" ? output.output : null;
|
|
35
|
+
// A tool call's arguments are not prose. Classifying a JSON object is a call spent on a question the
|
|
36
|
+
// classifier was not trained for — the PII guardrail is the one that reads arguments.
|
|
37
|
+
return null;
|
|
38
|
+
};
|
|
39
|
+
export const createModerationGuardrail = (options) => {
|
|
40
|
+
const subjects = options.subjects ?? ["input", "message"];
|
|
41
|
+
const minLength = options.minLength ?? 1;
|
|
42
|
+
const judge = async (text) => {
|
|
43
|
+
if (text.trim().length < minLength)
|
|
44
|
+
return { kind: "pass" };
|
|
45
|
+
const result = await options.classify(text);
|
|
46
|
+
if (!result.flagged)
|
|
47
|
+
return { kind: "pass" };
|
|
48
|
+
const categories = result.categories ?? [];
|
|
49
|
+
return {
|
|
50
|
+
kind: "refused",
|
|
51
|
+
code: "moderation",
|
|
52
|
+
// Categories, never the content. The message is shown to a person and stored in an event.
|
|
53
|
+
message: categories.length > 0
|
|
54
|
+
? `That content was flagged as ${categories.join(", ")}.`
|
|
55
|
+
: "That content was flagged by moderation.",
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
name: options.name ?? "moderation",
|
|
60
|
+
...(subjects.includes("input")
|
|
61
|
+
? { inspectInput: (input) => judge(input.text) }
|
|
62
|
+
: {}),
|
|
63
|
+
...(subjects.some((s) => s === "message" || s === "tool-result")
|
|
64
|
+
? {
|
|
65
|
+
inspectOutput: async (output) => {
|
|
66
|
+
if (!subjects.includes(output.kind))
|
|
67
|
+
return { kind: "pass" };
|
|
68
|
+
const text = textOf(output);
|
|
69
|
+
return text === null ? { kind: "pass" } : judge(text);
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
: {}),
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
//# sourceMappingURL=moderation.js.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal data, detected and redacted — REQ-046 (#205), task #212.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic and **offline**. No network call, no model call: a guardrail that costs a round trip per turn is
|
|
5
|
+
* one a deployment switches off under load, and the moment it is off is the moment it was needed. Everything
|
|
6
|
+
* here is a pattern plus, where one exists, a checksum.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a checksum matters more than a pattern
|
|
9
|
+
*
|
|
10
|
+
* A sixteen-digit order number matches every "card number" regex ever written. Flagging it teaches people that
|
|
11
|
+
* this guardrail cries wolf, and a guardrail people have learned to ignore is worse than none — they route
|
|
12
|
+
* around it. So card numbers are Luhn-checked and IBANs mod-97-checked, and a candidate that fails its checksum
|
|
13
|
+
* is *not* personal data, it is a number.
|
|
14
|
+
*
|
|
15
|
+
* ## Referential consistency, without state
|
|
16
|
+
*
|
|
17
|
+
* A placeholder is derived from a hash of the value, so the same value always yields the same placeholder —
|
|
18
|
+
* `[email:7a3f19]`. That is not a cosmetic choice. A model asked to compare two records, handed two *different*
|
|
19
|
+
* placeholders for one email, concludes the records differ and reasons wrongly about data it was never allowed
|
|
20
|
+
* to see. Deriving from the value keeps equality and nothing else.
|
|
21
|
+
*
|
|
22
|
+
* The alternative — a counter per turn — would need state keyed by run and would break the moment a run
|
|
23
|
+
* resumed in a different process, which is the normal case for a durable runtime.
|
|
24
|
+
*
|
|
25
|
+
* A hash placeholder does permit equality testing, and that is the intended trade: equality is exactly the
|
|
26
|
+
* property being preserved. It leaks no plaintext and is not reversible.
|
|
27
|
+
*/
|
|
28
|
+
import type { Guardrail } from "./index.js";
|
|
29
|
+
/** What to look for. Named so a record can say `email` without saying which email. */
|
|
30
|
+
export declare const PII_ENTITIES: readonly ["email", "phone", "card_number", "iban", "ssn", "ip_address"];
|
|
31
|
+
export type PiiEntity = (typeof PII_ENTITIES)[number];
|
|
32
|
+
/**
|
|
33
|
+
* What to do when something is found.
|
|
34
|
+
*
|
|
35
|
+
* `redact` by default, and the reasoning is worth stating because the other choice is defensible: refusing is
|
|
36
|
+
* safer and ruder. A support agent pasting a customer's email into a chat has done something ordinary, and a
|
|
37
|
+
* runtime that refuses the turn teaches them to paste it somewhere with no guardrail at all. Redaction keeps the
|
|
38
|
+
* conversation working while the value never reaches the model.
|
|
39
|
+
*
|
|
40
|
+
* `refuse` is right where the data must not have been sent at all — a card number, under most policies — so the
|
|
41
|
+
* default is per-entity rather than global.
|
|
42
|
+
*/
|
|
43
|
+
export type PiiAction = "redact" | "refuse";
|
|
44
|
+
export type PiiOptions = {
|
|
45
|
+
/** Which entities to look for. Defaults to all of them. */
|
|
46
|
+
readonly entities?: readonly PiiEntity[];
|
|
47
|
+
/** Per-entity action. Anything unlisted uses `defaultAction`. */
|
|
48
|
+
readonly actions?: Partial<Record<PiiEntity, PiiAction>>;
|
|
49
|
+
/** Defaults to `redact` — see `PiiAction`. */
|
|
50
|
+
readonly defaultAction?: PiiAction;
|
|
51
|
+
};
|
|
52
|
+
/** Six hex characters of a salted-by-entity hash: enough to distinguish values, short enough to read. */
|
|
53
|
+
export declare const placeholderFor: (entity: PiiEntity, value: string) => string;
|
|
54
|
+
export type PiiFinding = {
|
|
55
|
+
readonly entity: PiiEntity;
|
|
56
|
+
readonly value: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Every entity in a string, longest match first so a redaction cannot corrupt an overlapping one.
|
|
60
|
+
*
|
|
61
|
+
* Exported because it is the whole detector and it deserves to be tested directly rather than through a
|
|
62
|
+
* guardrail's verdict.
|
|
63
|
+
*/
|
|
64
|
+
export declare const findPii: (text: string, entities: readonly PiiEntity[]) => readonly PiiFinding[];
|
|
65
|
+
/** Replace every finding with its derived placeholder. Same value ⇒ same placeholder, always. */
|
|
66
|
+
export declare const redactText: (text: string, findings: readonly PiiFinding[]) => string;
|
|
67
|
+
/**
|
|
68
|
+
* A guardrail that finds personal data in the turn, in tool arguments and in tool results.
|
|
69
|
+
*
|
|
70
|
+
* All three, because each is a real path: the person types it, the model puts it in an argument, or a tool reads
|
|
71
|
+
* a document that contains it. A guardrail covering one of the three is a guardrail whose coverage nobody can
|
|
72
|
+
* state.
|
|
73
|
+
*/
|
|
74
|
+
export declare const createPiiGuardrail: (options?: PiiOptions) => Guardrail;
|
|
75
|
+
//# sourceMappingURL=pii.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Personal data, detected and redacted — REQ-046 (#205), task #212.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic and **offline**. No network call, no model call: a guardrail that costs a round trip per turn is
|
|
5
|
+
* one a deployment switches off under load, and the moment it is off is the moment it was needed. Everything
|
|
6
|
+
* here is a pattern plus, where one exists, a checksum.
|
|
7
|
+
*
|
|
8
|
+
* ## Why a checksum matters more than a pattern
|
|
9
|
+
*
|
|
10
|
+
* A sixteen-digit order number matches every "card number" regex ever written. Flagging it teaches people that
|
|
11
|
+
* this guardrail cries wolf, and a guardrail people have learned to ignore is worse than none — they route
|
|
12
|
+
* around it. So card numbers are Luhn-checked and IBANs mod-97-checked, and a candidate that fails its checksum
|
|
13
|
+
* is *not* personal data, it is a number.
|
|
14
|
+
*
|
|
15
|
+
* ## Referential consistency, without state
|
|
16
|
+
*
|
|
17
|
+
* A placeholder is derived from a hash of the value, so the same value always yields the same placeholder —
|
|
18
|
+
* `[email:7a3f19]`. That is not a cosmetic choice. A model asked to compare two records, handed two *different*
|
|
19
|
+
* placeholders for one email, concludes the records differ and reasons wrongly about data it was never allowed
|
|
20
|
+
* to see. Deriving from the value keeps equality and nothing else.
|
|
21
|
+
*
|
|
22
|
+
* The alternative — a counter per turn — would need state keyed by run and would break the moment a run
|
|
23
|
+
* resumed in a different process, which is the normal case for a durable runtime.
|
|
24
|
+
*
|
|
25
|
+
* A hash placeholder does permit equality testing, and that is the intended trade: equality is exactly the
|
|
26
|
+
* property being preserved. It leaks no plaintext and is not reversible.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
/** What to look for. Named so a record can say `email` without saying which email. */
|
|
30
|
+
export const PII_ENTITIES = ["email", "phone", "card_number", "iban", "ssn", "ip_address"];
|
|
31
|
+
const LUHN_OK = (digits) => {
|
|
32
|
+
let sum = 0;
|
|
33
|
+
let double = false;
|
|
34
|
+
for (let i = digits.length - 1; i >= 0; i -= 1) {
|
|
35
|
+
let d = Number(digits[i]);
|
|
36
|
+
if (Number.isNaN(d))
|
|
37
|
+
return false;
|
|
38
|
+
if (double)
|
|
39
|
+
d = d * 2 > 9 ? d * 2 - 9 : d * 2;
|
|
40
|
+
sum += d;
|
|
41
|
+
double = !double;
|
|
42
|
+
}
|
|
43
|
+
return digits.length >= 13 && sum % 10 === 0;
|
|
44
|
+
};
|
|
45
|
+
/** ISO 7064 mod-97: move the first four characters to the end, letters to digits, remainder must be 1. */
|
|
46
|
+
const IBAN_OK = (value) => {
|
|
47
|
+
const normalized = value.replace(/[\s-]/g, "").toUpperCase();
|
|
48
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(normalized))
|
|
49
|
+
return false;
|
|
50
|
+
const rearranged = normalized.slice(4) + normalized.slice(0, 4);
|
|
51
|
+
const expanded = [...rearranged].map((c) => (/[A-Z]/.test(c) ? String(c.charCodeAt(0) - 55) : c)).join("");
|
|
52
|
+
let remainder = 0;
|
|
53
|
+
for (const digit of expanded)
|
|
54
|
+
remainder = (remainder * 10 + Number(digit)) % 97;
|
|
55
|
+
return remainder === 1;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* A phone number, as distinct from any other run of digits.
|
|
59
|
+
*
|
|
60
|
+
* The corpus caught this: the first pattern matched an order number, an epoch timestamp and an invoice
|
|
61
|
+
* reference, giving 78.6% precision. A guardrail that fires on invoice numbers is one somebody switches off, and
|
|
62
|
+
* for this kind of check **precision matters more than recall** — a false positive is visible on every turn,
|
|
63
|
+
* a false negative is invisible until it matters, and only one of the two gets the guardrail disabled.
|
|
64
|
+
*
|
|
65
|
+
* Two conditions, both needed:
|
|
66
|
+
*
|
|
67
|
+
* - **9 to 15 digits.** Below nine and it is a date, an error code or a quantity; above fifteen and it is longer
|
|
68
|
+
* than E.164 permits, which usually means a card the Luhn check already rejected.
|
|
69
|
+
* - **A leading `+`, or at least two separators.** Humans group phone numbers — `020 7946 0958` — and machines
|
|
70
|
+
* do not group serial numbers. Two separators rather than one, because one is `INV-2026-0043`.
|
|
71
|
+
*
|
|
72
|
+
* A date like `2026-08-27` has two separators and is excluded by the digit count; a bare `1756300000` has the
|
|
73
|
+
* digits and is excluded by the grouping.
|
|
74
|
+
*/
|
|
75
|
+
const PHONE_OK = (candidate) => {
|
|
76
|
+
const digits = (candidate.match(/\d/g) ?? []).length;
|
|
77
|
+
if (digits < 9 || digits > 15)
|
|
78
|
+
return false;
|
|
79
|
+
const separators = (candidate.match(/[\s\-()]/g) ?? []).length;
|
|
80
|
+
return candidate.trimStart().startsWith("+") || separators >= 2;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Detectors, each a pattern and an optional validator.
|
|
84
|
+
*
|
|
85
|
+
* Order matters: `card_number` runs before `phone`, because a long digit run matches both and a card number is
|
|
86
|
+
* the more consequential reading. Getting that backwards would redact a card as a phone number and apply the
|
|
87
|
+
* phone policy to it.
|
|
88
|
+
*/
|
|
89
|
+
const DETECTORS = [
|
|
90
|
+
{ entity: "email", pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
|
|
91
|
+
{ entity: "iban", pattern: /\b[A-Z]{2}\d{2}[A-Z0-9 ]{10,34}\b/g, valid: IBAN_OK },
|
|
92
|
+
{ entity: "card_number", pattern: /\b(?:\d[ -]?){13,19}\b/g, valid: (m) => LUHN_OK(m.replace(/[ -]/g, "")) },
|
|
93
|
+
{ entity: "ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/g },
|
|
94
|
+
{ entity: "ip_address", pattern: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g },
|
|
95
|
+
// Last: the loosest pattern, so a card or an IBAN has already claimed its digits. And validated, because
|
|
96
|
+
// "a run of digits" is not a phone number — see PHONE_OK.
|
|
97
|
+
{ entity: "phone", pattern: /\+?\d[\d\s\-()]{7,20}\d/g, valid: PHONE_OK },
|
|
98
|
+
];
|
|
99
|
+
/** Six hex characters of a salted-by-entity hash: enough to distinguish values, short enough to read. */
|
|
100
|
+
export const placeholderFor = (entity, value) => `[${entity}:${createHash("sha256").update(`${entity}:${value}`).digest("hex").slice(0, 6)}]`;
|
|
101
|
+
/**
|
|
102
|
+
* Every entity in a string, longest match first so a redaction cannot corrupt an overlapping one.
|
|
103
|
+
*
|
|
104
|
+
* Exported because it is the whole detector and it deserves to be tested directly rather than through a
|
|
105
|
+
* guardrail's verdict.
|
|
106
|
+
*/
|
|
107
|
+
export const findPii = (text, entities) => {
|
|
108
|
+
const found = [];
|
|
109
|
+
const claimed = [];
|
|
110
|
+
for (const detector of DETECTORS) {
|
|
111
|
+
if (!entities.includes(detector.entity))
|
|
112
|
+
continue;
|
|
113
|
+
for (const match of text.matchAll(detector.pattern)) {
|
|
114
|
+
const start = match.index ?? 0;
|
|
115
|
+
const end = start + match[0].length;
|
|
116
|
+
// A span already claimed by an earlier (more specific) detector is not re-read as something looser.
|
|
117
|
+
if (claimed.some((c) => start < c.end && end > c.start))
|
|
118
|
+
continue;
|
|
119
|
+
const value = match[0].trim();
|
|
120
|
+
if (detector.valid && !detector.valid(value))
|
|
121
|
+
continue;
|
|
122
|
+
claimed.push({ start, end });
|
|
123
|
+
found.push({ entity: detector.entity, value });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return found;
|
|
127
|
+
};
|
|
128
|
+
/** Replace every finding with its derived placeholder. Same value ⇒ same placeholder, always. */
|
|
129
|
+
export const redactText = (text, findings) => {
|
|
130
|
+
let out = text;
|
|
131
|
+
// Longest first: replacing a short value that is a substring of a longer one would leave a fragment behind.
|
|
132
|
+
for (const finding of [...findings].sort((a, b) => b.value.length - a.value.length)) {
|
|
133
|
+
out = out.split(finding.value).join(placeholderFor(finding.entity, finding.value));
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
};
|
|
137
|
+
/** Walk any JSON-ish value, redacting strings. Tool arguments and results are objects, not prose. */
|
|
138
|
+
const redactDeep = (value, entities, found) => {
|
|
139
|
+
if (typeof value === "string") {
|
|
140
|
+
const hits = findPii(value, entities);
|
|
141
|
+
found.push(...hits);
|
|
142
|
+
return hits.length === 0 ? value : redactText(value, hits);
|
|
143
|
+
}
|
|
144
|
+
if (Array.isArray(value))
|
|
145
|
+
return value.map((v) => redactDeep(v, entities, found));
|
|
146
|
+
if (value !== null && typeof value === "object") {
|
|
147
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactDeep(v, entities, found)]));
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
};
|
|
151
|
+
/**
|
|
152
|
+
* A guardrail that finds personal data in the turn, in tool arguments and in tool results.
|
|
153
|
+
*
|
|
154
|
+
* All three, because each is a real path: the person types it, the model puts it in an argument, or a tool reads
|
|
155
|
+
* a document that contains it. A guardrail covering one of the three is a guardrail whose coverage nobody can
|
|
156
|
+
* state.
|
|
157
|
+
*/
|
|
158
|
+
export const createPiiGuardrail = (options = {}) => {
|
|
159
|
+
const entities = options.entities ?? [...PII_ENTITIES];
|
|
160
|
+
const defaultAction = options.defaultAction ?? "redact";
|
|
161
|
+
const actionFor = (entity) => options.actions?.[entity] ?? defaultAction;
|
|
162
|
+
const decide = (findings, redacted) => {
|
|
163
|
+
if (findings.length === 0)
|
|
164
|
+
return { kind: "pass" };
|
|
165
|
+
const refusing = [...new Set(findings.filter((f) => actionFor(f.entity) === "refuse").map((f) => f.entity))];
|
|
166
|
+
if (refusing.length > 0) {
|
|
167
|
+
return {
|
|
168
|
+
kind: "refused",
|
|
169
|
+
code: "pii_present",
|
|
170
|
+
// Names the entity type, never the value — the message is shown to a person and stored in an event.
|
|
171
|
+
message: `That content contains ${refusing.join(", ")}. Please remove it and try again.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { kind: "redacted", value: redacted(), what: [...new Set(findings.map((f) => f.entity))] };
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
name: "pii",
|
|
178
|
+
inspectInput(input) {
|
|
179
|
+
const findings = findPii(input.text, entities);
|
|
180
|
+
return decide(findings, () => ({ ...input, text: redactText(input.text, findings) }));
|
|
181
|
+
},
|
|
182
|
+
inspectOutput(output, _context) {
|
|
183
|
+
if (output.kind === "message") {
|
|
184
|
+
const findings = findPii(output.text, entities);
|
|
185
|
+
return decide(findings, () => ({ ...output, text: redactText(output.text, findings) }));
|
|
186
|
+
}
|
|
187
|
+
const found = [];
|
|
188
|
+
const payload = redactDeep(output.kind === "tool-call" ? output.input : output.output, entities, found);
|
|
189
|
+
return decide(found, () => output.kind === "tool-call" ? { ...output, input: payload } : { ...output, output: payload });
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
//# sourceMappingURL=pii.js.map
|
|
@@ -116,4 +116,5 @@ export declare const createEmbeddingPipeline: (deps: EmbeddingPipelineDeps) => {
|
|
|
116
116
|
export type EmbeddingPipeline = ReturnType<typeof createEmbeddingPipeline>;
|
|
117
117
|
export * from "./chunking.js";
|
|
118
118
|
export * from "./retrieval.js";
|
|
119
|
+
export * from "./navigate.js";
|
|
119
120
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/knowledge/index.js
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval without vectors — REQ-050 (#209), task #219, AC-4.
|
|
3
|
+
*
|
|
4
|
+
* A **spike**, and the deliverable is a decision with numbers rather than a subsystem. See
|
|
5
|
+
* `docs/26-retrieval-quality.md` for what it scored.
|
|
6
|
+
*
|
|
7
|
+
* ## The idea being tested
|
|
8
|
+
*
|
|
9
|
+
* Embedding-based retrieval matches a query against fragments of text and hopes the fragments it surfaces are the
|
|
10
|
+
* ones that answer it. A person looking something up in a manual does something else entirely: they read the
|
|
11
|
+
* table of contents, decide which chapter is relevant, and then read it. That needs no index, no embedding cost
|
|
12
|
+
* and no re-indexing when a document changes — and its citations name a *document* somebody chose rather than a
|
|
13
|
+
* fragment a cosine distance surfaced.
|
|
14
|
+
*
|
|
15
|
+
* The cost is a model call per query, and latency measured in seconds rather than milliseconds.
|
|
16
|
+
*
|
|
17
|
+
* ## Two things this prototype found immediately
|
|
18
|
+
*
|
|
19
|
+
* **`KnowledgeStore` cannot enumerate its sources.** There is `listBySource`, `get`, `deleteSource` and
|
|
20
|
+
* `staleSources`, and no way to ask "what documents are in here". That is correct for a vector-based design —
|
|
21
|
+
* nothing needed it — and it is exactly what a navigating retriever needs first. So the outline arrives through
|
|
22
|
+
* a port the *host* supplies (`OutlineCatalogue`), which is honest but means this mode is not a drop-in for a
|
|
23
|
+
* deployment that already has hybrid retrieval working.
|
|
24
|
+
*
|
|
25
|
+
* **It does fit behind `RetrievalMode`**, which the issue asked to test. `createRetriever` gains one optional
|
|
26
|
+
* dependency and a fourth mode; every caller — `search_knowledge` included — is unchanged, and a deployment that
|
|
27
|
+
* has not wired a navigator gets a named refusal rather than a silent fall back to semantic search. If it had
|
|
28
|
+
* *not* fit, that would have been the finding; it fits.
|
|
29
|
+
*/
|
|
30
|
+
import type { ExecutionContext } from "../core/context.js";
|
|
31
|
+
import type { KnowledgeChunk, KnowledgeStore } from "../persistence/index.js";
|
|
32
|
+
import type { RetrievalOutcome } from "./retrieval.js";
|
|
33
|
+
/** What a chooser sees of one document: enough to decide, and not the document itself. */
|
|
34
|
+
export type SourceOutline = {
|
|
35
|
+
readonly sourceType: KnowledgeChunk["sourceType"];
|
|
36
|
+
readonly sourceId: string;
|
|
37
|
+
readonly title: string;
|
|
38
|
+
/** The heading trail, in document order. This is the table of contents a person would read. */
|
|
39
|
+
readonly headings: readonly string[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Where the outline comes from.
|
|
43
|
+
*
|
|
44
|
+
* A port because the store cannot answer it (see the note above). A host that keeps documents in its own tables —
|
|
45
|
+
* which most do, since `KnowledgeStore` holds *chunks* — already has this list.
|
|
46
|
+
*/
|
|
47
|
+
export interface OutlineCatalogue {
|
|
48
|
+
list(context: {
|
|
49
|
+
readonly tenantId: ExecutionContext["tenantId"];
|
|
50
|
+
readonly authSubjects: readonly string[];
|
|
51
|
+
}): Promise<readonly SourceOutline[]>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whatever decides which documents to read. A model, in practice.
|
|
55
|
+
*
|
|
56
|
+
* A port rather than a model call, for the reason every model call in this package is a port: the platform must
|
|
57
|
+
* not acquire a provider, and a test must be able to pin the choice.
|
|
58
|
+
*/
|
|
59
|
+
export interface DocumentChooser {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
choose(input: {
|
|
62
|
+
readonly query: string;
|
|
63
|
+
readonly catalogue: readonly SourceOutline[];
|
|
64
|
+
readonly limit: number;
|
|
65
|
+
}): Promise<readonly string[]>;
|
|
66
|
+
}
|
|
67
|
+
export type NavigatorDeps = {
|
|
68
|
+
readonly store: KnowledgeStore;
|
|
69
|
+
readonly catalogue: OutlineCatalogue;
|
|
70
|
+
readonly chooser: DocumentChooser;
|
|
71
|
+
/** Documents the chooser may pick. More than a handful and the model is guessing rather than choosing. */
|
|
72
|
+
readonly maxSources?: number;
|
|
73
|
+
/** Chunks read per chosen document. A whole 200-page document would not fit the caller's context. */
|
|
74
|
+
readonly maxChunksPerSource?: number;
|
|
75
|
+
};
|
|
76
|
+
export declare const DEFAULT_MAX_SOURCES = 3;
|
|
77
|
+
export declare const DEFAULT_MAX_CHUNKS_PER_SOURCE = 40;
|
|
78
|
+
export interface Navigator {
|
|
79
|
+
readonly id: string;
|
|
80
|
+
navigate(context: {
|
|
81
|
+
readonly tenantId: ExecutionContext["tenantId"];
|
|
82
|
+
}, input: {
|
|
83
|
+
readonly query: string;
|
|
84
|
+
readonly authSubjects: readonly string[];
|
|
85
|
+
readonly limit: number;
|
|
86
|
+
}): Promise<RetrievalOutcome>;
|
|
87
|
+
}
|
|
88
|
+
export declare const createNavigator: (deps: NavigatorDeps) => Navigator;
|
|
89
|
+
//# sourceMappingURL=navigate.d.ts.map
|