@kuralle-agents/core 0.8.0 → 0.8.5
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 +6 -0
- package/dist/capabilities/validators/groundingValidator.d.ts +21 -0
- package/dist/capabilities/validators/groundingValidator.js +109 -0
- package/dist/escalation/escalation.d.ts +20 -0
- package/dist/escalation/escalation.js +85 -0
- package/dist/escalation/types.d.ts +40 -0
- package/dist/eval/simulation.d.ts +99 -0
- package/dist/eval/simulation.js +168 -0
- package/dist/index.d.ts +22 -2
- package/dist/index.js +10 -0
- package/dist/memory/factMemoryService.d.ts +28 -0
- package/dist/memory/factMemoryService.js +137 -0
- package/dist/memory/index.d.ts +1 -0
- package/dist/memory/index.js +1 -0
- package/dist/processors/builtin/moderationGuard.d.ts +24 -0
- package/dist/processors/builtin/moderationGuard.js +101 -0
- package/dist/processors/builtin/piiGuard.d.ts +41 -0
- package/dist/processors/builtin/piiGuard.js +143 -0
- package/dist/processors/builtin/promptInjectionGuard.d.ts +16 -0
- package/dist/processors/builtin/promptInjectionGuard.js +28 -0
- package/dist/runtime/Runtime.d.ts +53 -0
- package/dist/runtime/Runtime.js +184 -10
- package/dist/runtime/channels/TextDriver.js +6 -0
- package/dist/runtime/channels/VoiceDriver.js +6 -0
- package/dist/runtime/closeRun.js +4 -0
- package/dist/runtime/compaction.d.ts +51 -0
- package/dist/runtime/compaction.js +111 -0
- package/dist/runtime/hostLoop.d.ts +2 -0
- package/dist/runtime/hostLoop.js +1 -1
- package/dist/runtime/openRun.d.ts +5 -0
- package/dist/runtime/openRun.js +17 -0
- package/dist/runtime/policies/agentTurn.d.ts +4 -0
- package/dist/runtime/policies/agentTurn.js +35 -4
- package/dist/scheduler/index.d.ts +89 -0
- package/dist/scheduler/index.js +104 -0
- package/dist/types/channel.d.ts +2 -0
- package/dist/types/stream.d.ts +29 -0
- package/guides/GUARDRAILS.md +44 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { InMemoryConversationStore } from './conversations/index.js';
|
|
|
7
7
|
export { buildMarkOutcomeTool, OUTCOMES_MARK_TOOL_NAME } from './outcomes/index.js';
|
|
8
8
|
export { EvalRunner } from './eval/EvalRunner.js';
|
|
9
9
|
export { scoreTurn, aggregateScores } from './eval/scoring.js';
|
|
10
|
+
export { simulateConversation, createJudge, runSimulationSuite, DEFAULT_JUDGE_DIMENSIONS, } from './eval/simulation.js';
|
|
10
11
|
export { SessionWorkingMemory } from './runtime/WorkingMemory.js';
|
|
11
12
|
export { createTool, createToolWithFiller } from './tools/Tool.js';
|
|
12
13
|
export { createHandoffTool, isHandoffResult } from './tools/handoff.js';
|
|
@@ -20,6 +21,10 @@ export { HookRunner, createHookRunner } from './hooks/HookRunner.js';
|
|
|
20
21
|
export { loggingHooks, createLoggingHooks } from './hooks/builtin/logging.js';
|
|
21
22
|
export { createMetricsHooks, InMemoryMetrics } from './hooks/builtin/metrics.js';
|
|
22
23
|
export { createObservabilityHooks } from './hooks/builtin/observability.js';
|
|
24
|
+
export { createPromptInjectionGuard } from './processors/builtin/promptInjectionGuard.js';
|
|
25
|
+
export { createPiiInputGuard, createPiiOutputGuard, redactPii, } from './processors/builtin/piiGuard.js';
|
|
26
|
+
export { createModerationGuard, createModerationOutputGuard, } from './processors/builtin/moderationGuard.js';
|
|
27
|
+
export { createGroundingValidator } from './capabilities/validators/groundingValidator.js';
|
|
23
28
|
export { ToolEnforcer, createToolEnforcer } from './guards/ToolEnforcer.js';
|
|
24
29
|
export * as StopConditions from './guards/StopConditions.js';
|
|
25
30
|
export * from './guards/rules.js';
|
|
@@ -29,15 +34,20 @@ export { getTemplate, registerTemplate, listTemplates, getAllTemplates } from '.
|
|
|
29
34
|
export { InMemoryMemoryService } from './memory/index.js';
|
|
30
35
|
export { preloadMemoryContext } from './memory/index.js';
|
|
31
36
|
export { extractMemories } from './memory/index.js';
|
|
37
|
+
export { createFactMemoryService } from './memory/index.js';
|
|
32
38
|
export { FilePersistentMemoryStore, InMemoryPersistentMemoryStore, RoutedPersistentMemoryStore, TieredPersistentMemoryStore, scanMemoryWrite, buildMemoryBlockTool, DEFAULT_BLOCK_CHAR_LIMIT, DEFAULT_AUTO_LOAD_BLOCKS, } from './memory/index.js';
|
|
33
39
|
export { wireWorkingMemory, loadWorkingMemoryBlocks, formatWorkingMemorySection, resolveWorkingMemoryStore, } from './runtime/grounding/workingMemory.js';
|
|
34
40
|
export { resolveAgentWorkspace, } from './runtime/resolveAgentWorkspace.js';
|
|
35
41
|
export { DEFAULT_CONTEXT_BUDGET, VOICE_CONTEXT_BUDGET, computeMessageHistoryBudget, truncateToTokenBudget, formatMemoryWithBudget, estimateTokenCount, ContextBudget, } from './runtime/ContextBudget.js';
|
|
36
42
|
export { TokenAccumulator } from './runtime/TokenAccumulator.js';
|
|
43
|
+
export { compactMessages, estimateMessagesTokens, DEFAULT_COMPACTION_TRIGGER_TOKENS, DEFAULT_COMPACTION_KEEP_RECENT, } from './runtime/compaction.js';
|
|
44
|
+
export { isContextOverflowError, recoverFromContextOverflow, } from './runtime/contextOverflow.js';
|
|
37
45
|
export { applyPromptCache, applyAnthropicCacheControl, buildOpenAIResponsesProviderOptions, isAnthropicLanguageModel, isOpenAIResponsesModel, } from './runtime/promptCache.js';
|
|
38
46
|
export { handoffFilters, removeToolHistory, keepRecentMessages, removeKeys, composeFilters, } from './runtime/handoffFilters.js';
|
|
39
47
|
export { DefaultToolExecutor, DefaultConversationState, DefaultConversationEventLog, DefaultAgentStateController, createFoundation, } from './foundation/index.js';
|
|
40
48
|
export { CapabilityHost, ExtractionCapability, GuardrailCapability, AutoRetrieveCapability, PassThroughRefinement, PassThroughValidation, toGeminiDeclarations, toAISDKTools, } from './capabilities/index.js';
|
|
49
|
+
export { buildEscalationRequest, ensureSessionMetadata } from './escalation/escalation.js';
|
|
50
|
+
export { createInProcessScheduler, createWakeJobRunner, createScheduleFollowupTool, wakeJob, isWakeJob, WAKE_JOB_KIND, } from './scheduler/index.js';
|
|
41
51
|
export { filterAuditEntries } from './audit/index.js';
|
|
42
52
|
export { defineAgent, defineFlow, reply, collect, action, decide, confirmGate, } from './authoring/index.js';
|
|
43
53
|
export { defineTool } from './types/effectTool.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
import type { MemoryService } from './MemoryService.js';
|
|
3
|
+
import type { PersistentMemoryStore } from './blocks/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Fact-extracting cross-session memory, backed by a `PersistentMemoryStore`
|
|
6
|
+
* block per user (scope `user`, owner = `session.userId`, one fact per line).
|
|
7
|
+
*
|
|
8
|
+
* Unlike the raw-message ingestion path (`extractMemories`), this service
|
|
9
|
+
* runs an LLM merge at ingest: existing facts + the new transcript produce
|
|
10
|
+
* the COMPLETE updated fact list (still-true facts kept, changed ones
|
|
11
|
+
* updated, obsolete ones dropped) — so the block stays small, current, and
|
|
12
|
+
* deduplicated instead of growing append-only.
|
|
13
|
+
*
|
|
14
|
+
* Works on every block backend: file (node), Postgres/Redis adapters, and
|
|
15
|
+
* Cloudflare DO SQLite via `SqlPersistentMemoryStore`.
|
|
16
|
+
*/
|
|
17
|
+
export interface FactMemoryServiceOptions {
|
|
18
|
+
store: PersistentMemoryStore;
|
|
19
|
+
/** Extractor/merger model, run at temperature 0. */
|
|
20
|
+
model: LanguageModel;
|
|
21
|
+
/** Block key per user. Default: `'FACTS'`. */
|
|
22
|
+
blockKey?: string;
|
|
23
|
+
/** Char limit for the fact block. Default: `DEFAULT_BLOCK_CHAR_LIMIT`. */
|
|
24
|
+
charLimit?: number;
|
|
25
|
+
/** Max facts kept per user. Default: 25. */
|
|
26
|
+
maxFacts?: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function createFactMemoryService(options: FactMemoryServiceOptions): MemoryService;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { generateObject } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { DEFAULT_BLOCK_CHAR_LIMIT } from './blocks/types.js';
|
|
4
|
+
import { scanMemoryWrite } from './blocks/safetyScanner.js';
|
|
5
|
+
const factsSchema = z.object({
|
|
6
|
+
facts: z.array(z.string()),
|
|
7
|
+
});
|
|
8
|
+
function parseFactLines(content) {
|
|
9
|
+
return content
|
|
10
|
+
.split('\n')
|
|
11
|
+
.map((line) => line.replace(/^-\s*/, '').trim())
|
|
12
|
+
.filter((line) => line.length > 0);
|
|
13
|
+
}
|
|
14
|
+
function renderTranscript(session) {
|
|
15
|
+
const lines = [];
|
|
16
|
+
for (const message of session.messages) {
|
|
17
|
+
if (message.role !== 'user' && message.role !== 'assistant')
|
|
18
|
+
continue;
|
|
19
|
+
const content = typeof message.content === 'string'
|
|
20
|
+
? message.content
|
|
21
|
+
: Array.isArray(message.content)
|
|
22
|
+
? message.content
|
|
23
|
+
.filter((part) => part.type === 'text')
|
|
24
|
+
.map((part) => part.text)
|
|
25
|
+
.join('\n')
|
|
26
|
+
: '';
|
|
27
|
+
if (content.trim()) {
|
|
28
|
+
lines.push(`${message.role}: ${content}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return lines.join('\n');
|
|
32
|
+
}
|
|
33
|
+
const QUERY_TOKEN_MIN_LENGTH = 4;
|
|
34
|
+
function lexicalScore(query, fact) {
|
|
35
|
+
const factLower = fact.toLowerCase();
|
|
36
|
+
const tokens = query
|
|
37
|
+
.toLowerCase()
|
|
38
|
+
.split(/[^a-z0-9]+/)
|
|
39
|
+
.filter((token) => token.length >= QUERY_TOKEN_MIN_LENGTH);
|
|
40
|
+
if (tokens.length === 0)
|
|
41
|
+
return 0;
|
|
42
|
+
let hits = 0;
|
|
43
|
+
for (const token of tokens) {
|
|
44
|
+
if (factLower.includes(token))
|
|
45
|
+
hits += 1;
|
|
46
|
+
}
|
|
47
|
+
return hits / tokens.length;
|
|
48
|
+
}
|
|
49
|
+
export function createFactMemoryService(options) {
|
|
50
|
+
const blockKey = options.blockKey ?? 'FACTS';
|
|
51
|
+
const charLimit = options.charLimit ?? DEFAULT_BLOCK_CHAR_LIMIT;
|
|
52
|
+
const maxFacts = options.maxFacts ?? 25;
|
|
53
|
+
return {
|
|
54
|
+
async addSessionToMemory(session) {
|
|
55
|
+
if (!session.userId)
|
|
56
|
+
return;
|
|
57
|
+
const transcript = renderTranscript(session);
|
|
58
|
+
if (!transcript.trim())
|
|
59
|
+
return;
|
|
60
|
+
try {
|
|
61
|
+
const existing = await options.store.loadBlock('user', session.userId, blockKey);
|
|
62
|
+
const existingFacts = existing ? parseFactLines(existing.content) : [];
|
|
63
|
+
const { object } = await generateObject({
|
|
64
|
+
model: options.model,
|
|
65
|
+
schema: factsSchema,
|
|
66
|
+
temperature: 0,
|
|
67
|
+
system: [
|
|
68
|
+
'You maintain the long-term memory of a customer-facing assistant.',
|
|
69
|
+
'From the EXISTING FACTS and the NEW CONVERSATION, produce the complete updated fact list about this user.',
|
|
70
|
+
'Keep facts that are still true, update ones that changed, drop obsolete or duplicate ones.',
|
|
71
|
+
'Only durable facts worth remembering across conversations: stable preferences, profile details',
|
|
72
|
+
'(name, address, sizes), recurring context (orders they reference, their business).',
|
|
73
|
+
'Exclude one-off details, small talk, and sensitive payment data (card numbers, passwords).',
|
|
74
|
+
`At most ${maxFacts} facts, each a single self-contained sentence under 200 characters.`,
|
|
75
|
+
].join('\n'),
|
|
76
|
+
prompt: [
|
|
77
|
+
`EXISTING FACTS:\n${existingFacts.length > 0 ? existingFacts.map((f) => `- ${f}`).join('\n') : '(none)'}`,
|
|
78
|
+
`NEW CONVERSATION:\n${transcript}`,
|
|
79
|
+
].join('\n\n'),
|
|
80
|
+
});
|
|
81
|
+
const safeFacts = object.facts
|
|
82
|
+
.map((fact) => fact.trim())
|
|
83
|
+
.filter((fact) => fact.length > 0 && scanMemoryWrite(fact).safe)
|
|
84
|
+
.slice(0, maxFacts);
|
|
85
|
+
let content = safeFacts.map((fact) => `- ${fact}`).join('\n');
|
|
86
|
+
while (content.length > charLimit && safeFacts.length > 0) {
|
|
87
|
+
safeFacts.pop();
|
|
88
|
+
content = safeFacts.map((fact) => `- ${fact}`).join('\n');
|
|
89
|
+
}
|
|
90
|
+
await options.store.saveBlock({
|
|
91
|
+
key: blockKey,
|
|
92
|
+
scope: 'user',
|
|
93
|
+
content,
|
|
94
|
+
charLimit,
|
|
95
|
+
updatedAt: new Date().toISOString(),
|
|
96
|
+
}, session.userId);
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
// Memory must never take down a turn — ingest failures are logged, not thrown.
|
|
100
|
+
console.warn(`[Kuralle] fact-memory ingest failed for user ${session.userId}:`, error instanceof Error ? error.message : error);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async searchMemory(request) {
|
|
104
|
+
const block = await options.store.loadBlock('user', request.userId, blockKey);
|
|
105
|
+
if (!block) {
|
|
106
|
+
return { memories: [] };
|
|
107
|
+
}
|
|
108
|
+
const facts = parseFactLines(block.content);
|
|
109
|
+
const createdAt = block.updatedAt ? new Date(block.updatedAt) : new Date();
|
|
110
|
+
const limit = request.limit ?? 10;
|
|
111
|
+
const scored = facts.map((fact, index) => ({
|
|
112
|
+
fact,
|
|
113
|
+
index,
|
|
114
|
+
score: lexicalScore(request.query, fact),
|
|
115
|
+
}));
|
|
116
|
+
const relevant = scored.filter((entry) => entry.score > 0);
|
|
117
|
+
// Facts are few and curated: when nothing matches lexically, return them
|
|
118
|
+
// all (up to limit) — continuity beats false-negative emptiness.
|
|
119
|
+
const selected = (relevant.length > 0 ? relevant.sort((a, b) => b.score - a.score) : scored)
|
|
120
|
+
.slice(0, limit);
|
|
121
|
+
return {
|
|
122
|
+
memories: selected.map((entry) => ({
|
|
123
|
+
id: `${request.userId}:${blockKey}:${entry.index}`,
|
|
124
|
+
sessionId: 'fact-memory',
|
|
125
|
+
userId: request.userId,
|
|
126
|
+
content: entry.fact,
|
|
127
|
+
author: 'memory',
|
|
128
|
+
createdAt,
|
|
129
|
+
score: entry.score,
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
async deleteMemories(userId) {
|
|
134
|
+
await options.store.deleteBlock('user', userId, blockKey);
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
package/dist/memory/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export type { MemoryEntry, SearchMemoryRequest, SearchMemoryResponse, MemoryInge
|
|
|
3
3
|
export { InMemoryMemoryService } from './stores/InMemoryMemoryService.js';
|
|
4
4
|
export { preloadMemoryContext } from './preloadMemory.js';
|
|
5
5
|
export { extractMemories } from './utils.js';
|
|
6
|
+
export { createFactMemoryService, type FactMemoryServiceOptions, } from './factMemoryService.js';
|
|
6
7
|
export type { PersistentMemoryStore, PersistentMemoryBlock, PersistentMemoryConfig, MemoryBlockScope, } from './blocks/types.js';
|
|
7
8
|
export type { WorkingMemoryBlockSpec, WorkingMemoryConfig } from '../types/grounding.js';
|
|
8
9
|
export { DEFAULT_BLOCK_CHAR_LIMIT, DEFAULT_AUTO_LOAD_BLOCKS, } from './blocks/types.js';
|
package/dist/memory/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { InMemoryMemoryService } from './stores/InMemoryMemoryService.js';
|
|
2
2
|
export { preloadMemoryContext } from './preloadMemory.js';
|
|
3
3
|
export { extractMemories } from './utils.js';
|
|
4
|
+
export { createFactMemoryService, } from './factMemoryService.js';
|
|
4
5
|
export { DEFAULT_BLOCK_CHAR_LIMIT, DEFAULT_AUTO_LOAD_BLOCKS, } from './blocks/types.js';
|
|
5
6
|
export { InMemoryPersistentMemoryStore } from './blocks/InMemoryPersistentMemoryStore.js';
|
|
6
7
|
export { FilePersistentMemoryStore, } from './blocks/FilePersistentMemoryStore.js';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
import type { InputProcessor, OutputProcessor } from '../../types/processors.js';
|
|
3
|
+
export interface ModerationGuardOptions {
|
|
4
|
+
/** Classifier model. Use a small/fast model — this runs on every turn it guards. */
|
|
5
|
+
model: LanguageModel;
|
|
6
|
+
/** Policy categories to flag. Defaults cover the standard abuse set. */
|
|
7
|
+
categories?: string[];
|
|
8
|
+
/** User-facing message when content is flagged. */
|
|
9
|
+
message?: string;
|
|
10
|
+
/**
|
|
11
|
+
* What to do when the classifier itself errors. `allow` (default) fails open —
|
|
12
|
+
* deterministic guards still run and an outage does not take the agent down.
|
|
13
|
+
* `block` fails closed for zero-tolerance deployments.
|
|
14
|
+
*/
|
|
15
|
+
onError?: 'allow' | 'block';
|
|
16
|
+
id?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* LLM moderation guard over inbound user text. Runs the configured model as a
|
|
20
|
+
* temperature-0 classifier against the policy categories and blocks on a match.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createModerationGuard(options: ModerationGuardOptions): InputProcessor;
|
|
23
|
+
/** LLM moderation guard over assistant output — the post-turn counterpart. */
|
|
24
|
+
export declare function createModerationOutputGuard(options: ModerationGuardOptions): OutputProcessor;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { generateObject } from 'ai';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const DEFAULT_CATEGORIES = [
|
|
4
|
+
'violence or threats of violence',
|
|
5
|
+
'sexual content involving minors',
|
|
6
|
+
'self-harm encouragement or instructions',
|
|
7
|
+
'hate or harassment targeting a protected group',
|
|
8
|
+
'facilitation of clearly illegal activity (weapons, drugs, fraud)',
|
|
9
|
+
];
|
|
10
|
+
const moderationSchema = z.object({
|
|
11
|
+
flagged: z.boolean(),
|
|
12
|
+
category: z.union([z.string(), z.null()]),
|
|
13
|
+
rationale: z.union([z.string(), z.null()]),
|
|
14
|
+
});
|
|
15
|
+
async function classify(model, categories, role, text, abortSignal) {
|
|
16
|
+
const { object } = await generateObject({
|
|
17
|
+
model,
|
|
18
|
+
schema: moderationSchema,
|
|
19
|
+
temperature: 0,
|
|
20
|
+
abortSignal,
|
|
21
|
+
system: [
|
|
22
|
+
`You are a content-policy classifier. Flag the ${role} ONLY if it clearly falls into one of these categories:`,
|
|
23
|
+
...categories.map((category) => `- ${category}`),
|
|
24
|
+
'Ordinary rudeness, frustration, or sensitive-but-legitimate topics (health, finance, returns/refunds) are NOT flagged.',
|
|
25
|
+
'Respond with flagged=false unless the text is a clear match.',
|
|
26
|
+
].join('\n'),
|
|
27
|
+
prompt: text,
|
|
28
|
+
});
|
|
29
|
+
return object;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* LLM moderation guard over inbound user text. Runs the configured model as a
|
|
33
|
+
* temperature-0 classifier against the policy categories and blocks on a match.
|
|
34
|
+
*/
|
|
35
|
+
export function createModerationGuard(options) {
|
|
36
|
+
const categories = options.categories ?? DEFAULT_CATEGORIES;
|
|
37
|
+
const onError = options.onError ?? 'allow';
|
|
38
|
+
return {
|
|
39
|
+
id: options.id ?? 'moderation-guard',
|
|
40
|
+
name: 'Moderation guard',
|
|
41
|
+
description: 'LLM content-policy classifier over user input.',
|
|
42
|
+
process: async ({ input, context }) => {
|
|
43
|
+
let verdict;
|
|
44
|
+
try {
|
|
45
|
+
verdict = await classify(options.model, categories, 'user message', input, context.abortSignal);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
if (onError === 'block') {
|
|
49
|
+
return {
|
|
50
|
+
action: 'block',
|
|
51
|
+
reason: 'moderation-error',
|
|
52
|
+
message: options.message ?? "Sorry, I can't help with that.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { action: 'allow' };
|
|
56
|
+
}
|
|
57
|
+
if (!verdict.flagged) {
|
|
58
|
+
return { action: 'allow' };
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
action: 'block',
|
|
62
|
+
reason: `moderation: ${verdict.category ?? 'flagged'}`,
|
|
63
|
+
message: options.message ?? "Sorry, I can't help with that.",
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** LLM moderation guard over assistant output — the post-turn counterpart. */
|
|
69
|
+
export function createModerationOutputGuard(options) {
|
|
70
|
+
const categories = options.categories ?? DEFAULT_CATEGORIES;
|
|
71
|
+
const onError = options.onError ?? 'allow';
|
|
72
|
+
return {
|
|
73
|
+
id: options.id ?? 'moderation-output-guard',
|
|
74
|
+
name: 'Moderation output guard',
|
|
75
|
+
description: 'LLM content-policy classifier over assistant output.',
|
|
76
|
+
process: async ({ text, context }) => {
|
|
77
|
+
let verdict;
|
|
78
|
+
try {
|
|
79
|
+
verdict = await classify(options.model, categories, 'assistant response', text, context.abortSignal);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
if (onError === 'block') {
|
|
83
|
+
return {
|
|
84
|
+
action: 'block',
|
|
85
|
+
reason: 'moderation-error',
|
|
86
|
+
message: options.message ?? 'Sorry, I cannot provide that response.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return { action: 'allow' };
|
|
90
|
+
}
|
|
91
|
+
if (!verdict.flagged) {
|
|
92
|
+
return { action: 'allow' };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
action: 'block',
|
|
96
|
+
reason: `moderation: ${verdict.category ?? 'flagged'}`,
|
|
97
|
+
message: options.message ?? 'Sorry, I cannot provide that response.',
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { InputProcessor, OutputProcessor } from '../../types/processors.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic PII detection + redaction over message text.
|
|
4
|
+
*
|
|
5
|
+
* Detectors are conservative by default: `credit-card` (separator-tolerant,
|
|
6
|
+
* Luhn-validated so order/tracking numbers don't false-positive) and `email`.
|
|
7
|
+
* `phone` and `iban` are opt-in because their shapes collide with order ids,
|
|
8
|
+
* reference codes, and similar commerce artifacts.
|
|
9
|
+
*/
|
|
10
|
+
export type PiiDetector = 'credit-card' | 'email' | 'phone' | 'iban';
|
|
11
|
+
export interface PiiGuardOptions {
|
|
12
|
+
/** Which detectors to run. Default: `['credit-card', 'email']`. */
|
|
13
|
+
detect?: PiiDetector[];
|
|
14
|
+
/** `redact` replaces matches in place; `block` refuses the message. Default: `redact`. */
|
|
15
|
+
mode?: 'redact' | 'block';
|
|
16
|
+
/** User-facing message when `mode: 'block'` trips. */
|
|
17
|
+
message?: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface PiiMatch {
|
|
21
|
+
detector: PiiDetector;
|
|
22
|
+
matchedText: string;
|
|
23
|
+
}
|
|
24
|
+
export interface PiiScanResult {
|
|
25
|
+
text: string;
|
|
26
|
+
matches: PiiMatch[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Scan and redact PII in `text`. Returns the redacted text plus the list of
|
|
30
|
+
* matches (detector + original matched text) so callers can audit what was
|
|
31
|
+
* found without re-detecting.
|
|
32
|
+
*/
|
|
33
|
+
export declare function redactPii(text: string, detect?: PiiDetector[]): PiiScanResult;
|
|
34
|
+
/**
|
|
35
|
+
* PII guard over inbound user text. `redact` mode rewrites the message before
|
|
36
|
+
* the model (and persisted history) sees the raw value — credit-card redaction
|
|
37
|
+
* inbound is the PCI-relevant default for commerce agents.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createPiiInputGuard(options?: PiiGuardOptions): InputProcessor;
|
|
40
|
+
/** PII guard over assistant output — stops the model echoing sensitive values back. */
|
|
41
|
+
export declare function createPiiOutputGuard(options?: PiiGuardOptions): OutputProcessor;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const DEFAULT_DETECTORS = ['credit-card', 'email'];
|
|
2
|
+
const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
3
|
+
// 13-19 digits with optional single space/dash separators; candidates are
|
|
4
|
+
// Luhn-checked before redaction so plain long numbers survive.
|
|
5
|
+
const CARD_CANDIDATE_PATTERN = /\b(?:\d[ -]?){12,18}\d\b/g;
|
|
6
|
+
// Opt-in: international or separator-formatted numbers with 9+ digits total.
|
|
7
|
+
const PHONE_PATTERN = /(?:\+\d{1,3}[ -.]?)?(?:\(\d{2,4}\)[ -.]?)?\d{2,4}[ -.]\d{3,4}[ -.]?\d{3,4}\b|\+\d{9,15}\b/g;
|
|
8
|
+
const IBAN_PATTERN = /\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b/g;
|
|
9
|
+
function luhnValid(digits) {
|
|
10
|
+
let sum = 0;
|
|
11
|
+
let double = false;
|
|
12
|
+
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
|
13
|
+
let digit = digits.charCodeAt(index) - 48;
|
|
14
|
+
if (double) {
|
|
15
|
+
digit *= 2;
|
|
16
|
+
if (digit > 9)
|
|
17
|
+
digit -= 9;
|
|
18
|
+
}
|
|
19
|
+
sum += digit;
|
|
20
|
+
double = !double;
|
|
21
|
+
}
|
|
22
|
+
return sum % 10 === 0;
|
|
23
|
+
}
|
|
24
|
+
function ibanChecksumValid(candidate) {
|
|
25
|
+
const compact = candidate.replace(/\s+/g, '').toUpperCase();
|
|
26
|
+
if (compact.length < 15 || compact.length > 34)
|
|
27
|
+
return false;
|
|
28
|
+
const rearranged = compact.slice(4) + compact.slice(0, 4);
|
|
29
|
+
let remainder = 0;
|
|
30
|
+
for (const char of rearranged) {
|
|
31
|
+
const value = /[A-Z]/.test(char) ? String(char.charCodeAt(0) - 55) : char;
|
|
32
|
+
for (const digit of value) {
|
|
33
|
+
remainder = (remainder * 10 + (digit.charCodeAt(0) - 48)) % 97;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return remainder === 1;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Scan and redact PII in `text`. Returns the redacted text plus the list of
|
|
40
|
+
* matches (detector + original matched text) so callers can audit what was
|
|
41
|
+
* found without re-detecting.
|
|
42
|
+
*/
|
|
43
|
+
export function redactPii(text, detect = DEFAULT_DETECTORS) {
|
|
44
|
+
const matches = [];
|
|
45
|
+
let current = text;
|
|
46
|
+
if (detect.includes('credit-card')) {
|
|
47
|
+
current = current.replace(CARD_CANDIDATE_PATTERN, (candidate) => {
|
|
48
|
+
const digits = candidate.replace(/[ -]/g, '');
|
|
49
|
+
if (digits.length < 13 || digits.length > 19 || !luhnValid(digits)) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
matches.push({ detector: 'credit-card', matchedText: candidate });
|
|
53
|
+
return '[redacted card number]';
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (detect.includes('iban')) {
|
|
57
|
+
current = current.replace(IBAN_PATTERN, (candidate) => {
|
|
58
|
+
if (!ibanChecksumValid(candidate)) {
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
matches.push({ detector: 'iban', matchedText: candidate });
|
|
62
|
+
return '[redacted iban]';
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (detect.includes('email')) {
|
|
66
|
+
current = current.replace(EMAIL_PATTERN, (candidate) => {
|
|
67
|
+
matches.push({ detector: 'email', matchedText: candidate });
|
|
68
|
+
return '[redacted email]';
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (detect.includes('phone')) {
|
|
72
|
+
current = current.replace(PHONE_PATTERN, (candidate) => {
|
|
73
|
+
const digits = candidate.replace(/\D/g, '');
|
|
74
|
+
if (digits.length < 9 || digits.length > 15) {
|
|
75
|
+
return candidate;
|
|
76
|
+
}
|
|
77
|
+
matches.push({ detector: 'phone', matchedText: candidate });
|
|
78
|
+
return '[redacted phone]';
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return { text: current, matches };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* PII guard over inbound user text. `redact` mode rewrites the message before
|
|
85
|
+
* the model (and persisted history) sees the raw value — credit-card redaction
|
|
86
|
+
* inbound is the PCI-relevant default for commerce agents.
|
|
87
|
+
*/
|
|
88
|
+
export function createPiiInputGuard(options = {}) {
|
|
89
|
+
const detect = options.detect ?? DEFAULT_DETECTORS;
|
|
90
|
+
const mode = options.mode ?? 'redact';
|
|
91
|
+
return {
|
|
92
|
+
id: options.id ?? 'pii-input-guard',
|
|
93
|
+
name: 'PII input guard',
|
|
94
|
+
description: `Detects ${detect.join(', ')} in user input and ${mode}s it.`,
|
|
95
|
+
process: ({ input }) => {
|
|
96
|
+
const scan = redactPii(input, detect);
|
|
97
|
+
if (scan.matches.length === 0) {
|
|
98
|
+
return { action: 'allow' };
|
|
99
|
+
}
|
|
100
|
+
if (mode === 'block') {
|
|
101
|
+
return {
|
|
102
|
+
action: 'block',
|
|
103
|
+
reason: `pii-detected: ${scan.matches.map((m) => m.detector).join(', ')}`,
|
|
104
|
+
message: options.message ??
|
|
105
|
+
'For your security, please do not share sensitive details like card numbers in chat.',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
action: 'modify',
|
|
110
|
+
input: scan.text,
|
|
111
|
+
reason: `pii-redacted: ${scan.matches.map((m) => m.detector).join(', ')}`,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** PII guard over assistant output — stops the model echoing sensitive values back. */
|
|
117
|
+
export function createPiiOutputGuard(options = {}) {
|
|
118
|
+
const detect = options.detect ?? DEFAULT_DETECTORS;
|
|
119
|
+
const mode = options.mode ?? 'redact';
|
|
120
|
+
return {
|
|
121
|
+
id: options.id ?? 'pii-output-guard',
|
|
122
|
+
name: 'PII output guard',
|
|
123
|
+
description: `Detects ${detect.join(', ')} in assistant output and ${mode}s it.`,
|
|
124
|
+
process: ({ text }) => {
|
|
125
|
+
const scan = redactPii(text, detect);
|
|
126
|
+
if (scan.matches.length === 0) {
|
|
127
|
+
return { action: 'allow' };
|
|
128
|
+
}
|
|
129
|
+
if (mode === 'block') {
|
|
130
|
+
return {
|
|
131
|
+
action: 'block',
|
|
132
|
+
reason: `pii-detected: ${scan.matches.map((m) => m.detector).join(', ')}`,
|
|
133
|
+
message: options.message ?? 'Sorry, I cannot share that information.',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
action: 'modify',
|
|
138
|
+
text: scan.text,
|
|
139
|
+
reason: `pii-redacted: ${scan.matches.map((m) => m.detector).join(', ')}`,
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InputProcessor } from '../../types/processors.js';
|
|
2
|
+
export interface PromptInjectionGuardOptions {
|
|
3
|
+
/** User-facing message when a pattern matches. */
|
|
4
|
+
message?: string;
|
|
5
|
+
id?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic prompt-injection guard over inbound user text. Reuses the
|
|
9
|
+
* injection pattern set that already protects persistent memory writes
|
|
10
|
+
* (`scanMemoryWrite`) — one audited pattern source, two enforcement points.
|
|
11
|
+
*
|
|
12
|
+
* False positive cost: the turn is refused with a polite message and the user
|
|
13
|
+
* can rephrase. False negative cost: instruction override — so the patterns
|
|
14
|
+
* err toward catching more.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createPromptInjectionGuard(options?: PromptInjectionGuardOptions): InputProcessor;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { scanMemoryWrite } from '../../memory/blocks/safetyScanner.js';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic prompt-injection guard over inbound user text. Reuses the
|
|
4
|
+
* injection pattern set that already protects persistent memory writes
|
|
5
|
+
* (`scanMemoryWrite`) — one audited pattern source, two enforcement points.
|
|
6
|
+
*
|
|
7
|
+
* False positive cost: the turn is refused with a polite message and the user
|
|
8
|
+
* can rephrase. False negative cost: instruction override — so the patterns
|
|
9
|
+
* err toward catching more.
|
|
10
|
+
*/
|
|
11
|
+
export function createPromptInjectionGuard(options = {}) {
|
|
12
|
+
return {
|
|
13
|
+
id: options.id ?? 'prompt-injection-guard',
|
|
14
|
+
name: 'Prompt injection guard',
|
|
15
|
+
description: 'Blocks user input matching known prompt-injection patterns.',
|
|
16
|
+
process: ({ input }) => {
|
|
17
|
+
const scan = scanMemoryWrite(input);
|
|
18
|
+
if (scan.safe) {
|
|
19
|
+
return { action: 'allow' };
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
action: 'block',
|
|
23
|
+
reason: `prompt-injection: ${scan.matchedPattern}`,
|
|
24
|
+
message: options.message ?? "Sorry, I can't act on that request.",
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -15,6 +15,9 @@ import type { classifyHostTarget, selectHostTarget } from './select.js';
|
|
|
15
15
|
import type { KnowledgeProviderConfig } from '../types/voice.js';
|
|
16
16
|
import type { MemoryService as V1MemoryService } from '../memory/MemoryService.js';
|
|
17
17
|
import type { PersistentMemoryStore } from '../memory/blocks/types.js';
|
|
18
|
+
import { type CompactionConfig } from './compaction.js';
|
|
19
|
+
import type { EscalationConfig } from '../escalation/types.js';
|
|
20
|
+
import type { WakeOptions } from '../scheduler/index.js';
|
|
18
21
|
export interface HarnessConfig {
|
|
19
22
|
agents: AgentConfig[];
|
|
20
23
|
defaultAgentId: string;
|
|
@@ -38,12 +41,34 @@ export interface HarnessConfig {
|
|
|
38
41
|
* text-only models. When omitted, audio parts pass through to audio-capable models.
|
|
39
42
|
*/
|
|
40
43
|
transcriptionModel?: TranscriptionModel;
|
|
44
|
+
/**
|
|
45
|
+
* Automatic history compaction. When set, the runtime summarizes older
|
|
46
|
+
* messages into one system note after any turn whose history exceeds
|
|
47
|
+
* `triggerTokens` (off the user's latency path), and force-compacts once as
|
|
48
|
+
* the retry step after a provider context-overflow error.
|
|
49
|
+
*/
|
|
50
|
+
compaction?: CompactionConfig;
|
|
51
|
+
/**
|
|
52
|
+
* Escalation-to-human pipeline. When set, any escalation — a terminal
|
|
53
|
+
* handoff (`handoffs: ['human']`, validator `escalate` decision, host
|
|
54
|
+
* control) or a flow `escalate()` pause — builds an `EscalationRequest`
|
|
55
|
+
* (state snapshot + recent messages + optional LLM handoff brief) and
|
|
56
|
+
* invokes the handler. Resume with `runtime.resumeFromEscalation()`.
|
|
57
|
+
*/
|
|
58
|
+
escalation?: EscalationConfig;
|
|
41
59
|
}
|
|
42
60
|
export interface RunOptions {
|
|
43
61
|
sessionId?: string;
|
|
44
62
|
/** The user turn: plain text, or AI SDK multimodal content (text + file/image/audio parts). */
|
|
45
63
|
input?: UserInputContent;
|
|
46
64
|
selection?: ResolvedSelection;
|
|
65
|
+
/**
|
|
66
|
+
* Agent-initiated (proactive) turn — mutually exclusive with `input`. The
|
|
67
|
+
* runtime appends a wake note instead of a user message and runs the normal
|
|
68
|
+
* loop: free-conversation agents proactively re-engage; an active flow
|
|
69
|
+
* re-prompts its current step. Schedule wakes with `createWakeJobRunner`.
|
|
70
|
+
*/
|
|
71
|
+
wake?: WakeOptions;
|
|
47
72
|
userId?: string;
|
|
48
73
|
agentId?: string;
|
|
49
74
|
seedMessages?: ModelMessage[];
|
|
@@ -74,6 +99,34 @@ export declare class Runtime {
|
|
|
74
99
|
reason?: string;
|
|
75
100
|
markedBy?: ConversationOutcomeMarkedBy;
|
|
76
101
|
}): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* Compact `runState.messages` when over the configured trigger (or always,
|
|
104
|
+
* when `force`). Persists both the run state and the session message mirror.
|
|
105
|
+
* Returns whether compaction applied.
|
|
106
|
+
*/
|
|
107
|
+
private applyCompaction;
|
|
108
|
+
/**
|
|
109
|
+
* Context-overflow recovery: strip the failed turn's partial assistant/tool
|
|
110
|
+
* messages (the user's own message is preserved), force one compaction, and
|
|
111
|
+
* let the caller retry the turn once.
|
|
112
|
+
*/
|
|
113
|
+
private recoverFromOverflow;
|
|
77
114
|
getConversationLength(sessionId: string): Promise<number>;
|
|
115
|
+
/**
|
|
116
|
+
* Build the escalation request, invoke the configured handler, record the
|
|
117
|
+
* outcome on session metadata, and emit the `escalation` stream part.
|
|
118
|
+
* No-op without `config.escalation`. Handler errors become a `failed`
|
|
119
|
+
* outcome — escalation must never take down the turn.
|
|
120
|
+
*/
|
|
121
|
+
private dispatchEscalation;
|
|
122
|
+
/**
|
|
123
|
+
* Hand the conversation back to the bot after a human resolved an
|
|
124
|
+
* escalation: appends a resolution note the model will see, clears any
|
|
125
|
+
* parked flow/escalation state, and marks the run runnable again. The next
|
|
126
|
+
* `run()` continues the conversation with full context.
|
|
127
|
+
*/
|
|
128
|
+
resumeFromEscalation(sessionId: string, opts?: {
|
|
129
|
+
resolutionSummary?: string;
|
|
130
|
+
}): Promise<void>;
|
|
78
131
|
}
|
|
79
132
|
export declare function createRuntime(config: HarnessConfig): Runtime;
|