@kuralle-agents/core 0.8.0 → 0.9.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 +8 -2
- 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 +24 -3
- package/dist/index.js +12 -1
- 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 +8 -2
- package/dist/runtime/channels/VoiceDriver.js +6 -0
- package/dist/runtime/channels/index.d.ts +1 -1
- package/dist/runtime/channels/index.js +1 -1
- package/dist/runtime/channels/inputBuffer.d.ts +4 -1
- package/dist/runtime/channels/inputBuffer.js +8 -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/index.d.ts +2 -2
- package/dist/runtime/index.js +2 -2
- 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/runtime/userInput.d.ts +2 -0
- package/dist/runtime/userInput.js +20 -0
- 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
|
@@ -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;
|
package/dist/runtime/Runtime.js
CHANGED
|
@@ -10,7 +10,7 @@ import { hostLoop } from './hostLoop.js';
|
|
|
10
10
|
import { isDegradableRuntimeError } from '../flow/degradableErrors.js';
|
|
11
11
|
import { SAFE_DEGRADED_MESSAGE } from '../flow/degrade.js';
|
|
12
12
|
import { adaptHostSelect } from './hostClassifyAdapter.js';
|
|
13
|
-
import { openRun } from './openRun.js';
|
|
13
|
+
import { openRun, sessionDerivedRunId } from './openRun.js';
|
|
14
14
|
import { closeRun } from './closeRun.js';
|
|
15
15
|
import { SessionRunStore } from './durable/SessionRunStore.js';
|
|
16
16
|
import { loadRecordedSteps } from './durable/replay.js';
|
|
@@ -18,6 +18,9 @@ import { markSessionOutcome } from './outcomeMarking.js';
|
|
|
18
18
|
import { resolveAgentPolicies } from './policies/resolvePolicies.js';
|
|
19
19
|
import { buildAutoRetrieveProvider, buildKnowledgeProvider, buildMemoryService, runMemoryIngest, } from './grounding/index.js';
|
|
20
20
|
import { SessionMutex } from './SessionMutex.js';
|
|
21
|
+
import { compactMessages } from './compaction.js';
|
|
22
|
+
import { isContextOverflowError, recoverFromContextOverflow } from './contextOverflow.js';
|
|
23
|
+
import { buildEscalationRequest, recordEscalationOutcome, ESCALATION_NOTIFIED_KEY, } from '../escalation/escalation.js';
|
|
21
24
|
export class Runtime {
|
|
22
25
|
config;
|
|
23
26
|
agentsById;
|
|
@@ -38,6 +41,9 @@ export class Runtime {
|
|
|
38
41
|
this.hooks = config.hooks;
|
|
39
42
|
}
|
|
40
43
|
run(opts) {
|
|
44
|
+
if (opts.wake && opts.input !== undefined) {
|
|
45
|
+
throw new Error('RunOptions.wake and RunOptions.input are mutually exclusive');
|
|
46
|
+
}
|
|
41
47
|
const sessionId = opts.sessionId || randomUUID();
|
|
42
48
|
const bus = createEventBus();
|
|
43
49
|
const abortController = new AbortController();
|
|
@@ -56,6 +62,7 @@ export class Runtime {
|
|
|
56
62
|
userId: opts.userId,
|
|
57
63
|
input: opts.input,
|
|
58
64
|
selection: opts.selection,
|
|
65
|
+
wake: opts.wake,
|
|
59
66
|
agentId: opts.agentId,
|
|
60
67
|
seedMessages: opts.seedMessages,
|
|
61
68
|
historyDelta: opts.historyDelta,
|
|
@@ -121,26 +128,41 @@ export class Runtime {
|
|
|
121
128
|
runCtx.workingMemoryPrompt = openingSurface.workingMemoryPrompt;
|
|
122
129
|
runCtx.workingMemoryTools = openingSurface.workingMemoryTools;
|
|
123
130
|
await this.hooks?.onStart?.(runCtx);
|
|
131
|
+
if (opts.wake) {
|
|
132
|
+
emit({ type: 'wake', reason: opts.wake.reason });
|
|
133
|
+
}
|
|
124
134
|
const driver = opts.driver ?? new TextDriver();
|
|
125
135
|
let activeAgent = opened.agent;
|
|
126
136
|
let loopResult = { kind: 'turnComplete' };
|
|
127
137
|
let handoffCount = 0;
|
|
128
138
|
let terminalOutcome;
|
|
139
|
+
let overflowRetried = false;
|
|
129
140
|
try {
|
|
130
|
-
for (;;) {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
turnLoop: for (;;) {
|
|
142
|
+
try {
|
|
143
|
+
loopResult = await hostLoop({
|
|
144
|
+
agent: activeAgent,
|
|
145
|
+
run: runCtx.runState,
|
|
146
|
+
driver,
|
|
147
|
+
ctx: runCtx,
|
|
148
|
+
classify: this.config.hostClassify ??
|
|
149
|
+
(this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (!overflowRetried && this.config.compaction && isContextOverflowError(error)) {
|
|
154
|
+
overflowRetried = true;
|
|
155
|
+
await this.recoverFromOverflow(runCtx, activeAgent, emit);
|
|
156
|
+
continue turnLoop;
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
139
160
|
if (loopResult.kind === 'handoff') {
|
|
140
161
|
if (this.terminalHandoffTargets.has(loopResult.to)) {
|
|
141
162
|
emit({ type: 'handoff', targetAgent: loopResult.to, reason: loopResult.reason });
|
|
142
163
|
runCtx.runState.status = 'paused';
|
|
143
164
|
await runCtx.runStore.putRunState(runCtx.runState);
|
|
165
|
+
await this.dispatchEscalation(runCtx, activeAgent, { reason: loopResult.reason ?? 'handoff_to_human', category: loopResult.category }, emit, { setLatch: false });
|
|
144
166
|
break;
|
|
145
167
|
}
|
|
146
168
|
handoffCount += 1;
|
|
@@ -183,10 +205,20 @@ export class Runtime {
|
|
|
183
205
|
break;
|
|
184
206
|
}
|
|
185
207
|
if (loopResult.kind === 'paused') {
|
|
208
|
+
if (runCtx.runState.waitingFor?.signalName === '__escalate') {
|
|
209
|
+
// Flow escalate() parks on the durable signal — notify the human
|
|
210
|
+
// side now; the latch keeps the post-resume terminal handoff from
|
|
211
|
+
// notifying a second time.
|
|
212
|
+
const meta = runCtx.runState.waitingFor.meta;
|
|
213
|
+
await this.dispatchEscalation(runCtx, activeAgent, { reason: String(meta?.reason ?? 'flow_escalation') }, emit, { setLatch: true });
|
|
214
|
+
}
|
|
186
215
|
break;
|
|
187
216
|
}
|
|
188
217
|
break;
|
|
189
218
|
}
|
|
219
|
+
// Post-turn maintenance: text already streamed, so the summarizer call
|
|
220
|
+
// is off the user's latency path; the NEXT turn starts compact.
|
|
221
|
+
await this.applyCompaction(runCtx, activeAgent, emit, false);
|
|
190
222
|
}
|
|
191
223
|
catch (error) {
|
|
192
224
|
await this.hooks?.onError?.(runCtx, error);
|
|
@@ -276,11 +308,153 @@ export class Runtime {
|
|
|
276
308
|
markedBy: opts?.markedBy ?? 'http',
|
|
277
309
|
});
|
|
278
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Compact `runState.messages` when over the configured trigger (or always,
|
|
313
|
+
* when `force`). Persists both the run state and the session message mirror.
|
|
314
|
+
* Returns whether compaction applied.
|
|
315
|
+
*/
|
|
316
|
+
async applyCompaction(runCtx, agent, emit, force) {
|
|
317
|
+
const config = this.config.compaction;
|
|
318
|
+
if (!config) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const model = config.model ?? agent.controlModel ?? agent.model ?? this.defaultModel;
|
|
322
|
+
if (!model) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
const result = await compactMessages({
|
|
326
|
+
messages: runCtx.runState.messages,
|
|
327
|
+
model,
|
|
328
|
+
config,
|
|
329
|
+
force,
|
|
330
|
+
});
|
|
331
|
+
if (!result.compacted) {
|
|
332
|
+
if (force) {
|
|
333
|
+
emit({ type: 'compaction-skipped', reason: result.reason });
|
|
334
|
+
}
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
runCtx.runState.messages = result.messages;
|
|
338
|
+
runCtx.runState.updatedAt = Date.now();
|
|
339
|
+
await runCtx.runStore.putRunState(runCtx.runState);
|
|
340
|
+
const latest = await this.sessionStore.get(runCtx.session.id);
|
|
341
|
+
if (latest) {
|
|
342
|
+
latest.messages = [...result.messages];
|
|
343
|
+
await this.sessionStore.save(latest);
|
|
344
|
+
}
|
|
345
|
+
runCtx.session.messages = [...result.messages];
|
|
346
|
+
emit({
|
|
347
|
+
type: 'context-compacted',
|
|
348
|
+
beforeTokens: result.beforeTokens,
|
|
349
|
+
afterTokens: result.afterTokens,
|
|
350
|
+
summarizedCount: result.summarizedCount,
|
|
351
|
+
});
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Context-overflow recovery: strip the failed turn's partial assistant/tool
|
|
356
|
+
* messages (the user's own message is preserved), force one compaction, and
|
|
357
|
+
* let the caller retry the turn once.
|
|
358
|
+
*/
|
|
359
|
+
async recoverFromOverflow(runCtx, agent, emit) {
|
|
360
|
+
runCtx.session.messages = runCtx.runState.messages;
|
|
361
|
+
const recovery = await recoverFromContextOverflow(runCtx.session);
|
|
362
|
+
runCtx.runState.messages = runCtx.session.messages;
|
|
363
|
+
const compacted = await this.applyCompaction(runCtx, agent, emit, true);
|
|
364
|
+
emit({
|
|
365
|
+
type: 'context-overflow-recovered',
|
|
366
|
+
strippedCount: recovery.strippedCount,
|
|
367
|
+
compacted,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
279
370
|
async getConversationLength(sessionId) {
|
|
280
371
|
const runStore = new SessionRunStore(this.sessionStore, sessionId);
|
|
281
372
|
const runState = await runStore.getRunState(sessionId);
|
|
282
373
|
return runState?.messages.length ?? 0;
|
|
283
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* Build the escalation request, invoke the configured handler, record the
|
|
377
|
+
* outcome on session metadata, and emit the `escalation` stream part.
|
|
378
|
+
* No-op without `config.escalation`. Handler errors become a `failed`
|
|
379
|
+
* outcome — escalation must never take down the turn.
|
|
380
|
+
*/
|
|
381
|
+
async dispatchEscalation(runCtx, agent, info, emit, opts) {
|
|
382
|
+
const config = this.config.escalation;
|
|
383
|
+
if (!config) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (!opts.setLatch && runCtx.runState.state[ESCALATION_NOTIFIED_KEY]) {
|
|
387
|
+
// The handler already fired when the flow parked on `__escalate`;
|
|
388
|
+
// consume the latch instead of notifying twice.
|
|
389
|
+
delete runCtx.runState.state[ESCALATION_NOTIFIED_KEY];
|
|
390
|
+
await runCtx.runStore.putRunState(runCtx.runState);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const model = config.model ?? agent.controlModel ?? agent.model ?? this.defaultModel;
|
|
394
|
+
const request = await buildEscalationRequest({
|
|
395
|
+
session: runCtx.session,
|
|
396
|
+
runState: runCtx.runState,
|
|
397
|
+
reason: info.reason,
|
|
398
|
+
category: info.category,
|
|
399
|
+
config,
|
|
400
|
+
model,
|
|
401
|
+
});
|
|
402
|
+
let outcome;
|
|
403
|
+
try {
|
|
404
|
+
outcome = await config.handler(request);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
outcome = {
|
|
408
|
+
status: 'failed',
|
|
409
|
+
error: error instanceof Error ? error.message : String(error),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
recordEscalationOutcome(runCtx.session, info.category ?? 'user-request', outcome);
|
|
413
|
+
if (opts.setLatch) {
|
|
414
|
+
runCtx.runState.state[ESCALATION_NOTIFIED_KEY] = true;
|
|
415
|
+
}
|
|
416
|
+
await runCtx.runStore.putRunState(runCtx.runState);
|
|
417
|
+
await this.sessionStore.save(runCtx.session);
|
|
418
|
+
emit({
|
|
419
|
+
type: 'escalation',
|
|
420
|
+
reason: info.reason,
|
|
421
|
+
category: info.category,
|
|
422
|
+
outcome: outcome.status,
|
|
423
|
+
summary: request.summary,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Hand the conversation back to the bot after a human resolved an
|
|
428
|
+
* escalation: appends a resolution note the model will see, clears any
|
|
429
|
+
* parked flow/escalation state, and marks the run runnable again. The next
|
|
430
|
+
* `run()` continues the conversation with full context.
|
|
431
|
+
*/
|
|
432
|
+
async resumeFromEscalation(sessionId, opts) {
|
|
433
|
+
const session = await this.sessionStore.get(sessionId);
|
|
434
|
+
if (!session) {
|
|
435
|
+
throw new Error(`Session not found: ${sessionId}`);
|
|
436
|
+
}
|
|
437
|
+
const runStore = new SessionRunStore(this.sessionStore, sessionId);
|
|
438
|
+
const runState = await runStore.getRunState(sessionDerivedRunId(sessionId));
|
|
439
|
+
if (!runState) {
|
|
440
|
+
throw new Error(`No run state for session: ${sessionId}`);
|
|
441
|
+
}
|
|
442
|
+
const note = {
|
|
443
|
+
role: 'system',
|
|
444
|
+
content: `[A human agent handled this conversation${opts?.resolutionSummary ? `. Resolution: ${opts.resolutionSummary}` : ''}. The assistant is now resuming.]`,
|
|
445
|
+
};
|
|
446
|
+
runState.messages = [...runState.messages, note];
|
|
447
|
+
runState.status = 'running';
|
|
448
|
+
runState.waitingFor = undefined;
|
|
449
|
+
runState.activeFlow = undefined;
|
|
450
|
+
runState.activeNode = undefined;
|
|
451
|
+
delete runState.state[ESCALATION_NOTIFIED_KEY];
|
|
452
|
+
runState.updatedAt = Date.now();
|
|
453
|
+
await runStore.putRunState(runState);
|
|
454
|
+
const latest = (await this.sessionStore.get(sessionId)) ?? session;
|
|
455
|
+
latest.messages = [...runState.messages];
|
|
456
|
+
await this.sessionStore.save(latest);
|
|
457
|
+
}
|
|
284
458
|
}
|
|
285
459
|
export function createRuntime(config) {
|
|
286
460
|
return new Runtime(config);
|
|
@@ -2,7 +2,7 @@ import { streamText } from 'ai';
|
|
|
2
2
|
import { buildNodePrompt, resolveInstructions, composeSystem } from '../../flow/nodeBuilders.js';
|
|
3
3
|
import { buildToolSet } from '../../tools/effect/index.js';
|
|
4
4
|
import { executeModelToolCall, toolResultMessage } from './executeModelTool.js';
|
|
5
|
-
import {
|
|
5
|
+
import { consumeAllPendingUserInput } from './inputBuffer.js';
|
|
6
6
|
import { runSilentExtraction } from './extractionTurn.js';
|
|
7
7
|
import { applyPreTurnPolicies, applyPostTurnPolicies } from '../policies/agentTurn.js';
|
|
8
8
|
import { resolveMaxSteps } from '../policies/limits.js';
|
|
@@ -28,6 +28,12 @@ export class TextDriver {
|
|
|
28
28
|
const preTurn = await applyPreTurnPolicies(ctx);
|
|
29
29
|
if (!preTurn.proceed) {
|
|
30
30
|
const blocked = preTurn.blockedMessage ?? 'Input blocked by guardrails';
|
|
31
|
+
ctx.emit({
|
|
32
|
+
type: 'safety-blocked',
|
|
33
|
+
moderator: preTurn.blockedBy ?? 'input-guardrails',
|
|
34
|
+
rationale: preTurn.blockedReason ?? 'input blocked',
|
|
35
|
+
userFacingMessage: blocked,
|
|
36
|
+
});
|
|
31
37
|
const id = crypto.randomUUID();
|
|
32
38
|
ctx.emit({ type: 'text-start', id });
|
|
33
39
|
ctx.emit({ type: 'text-delta', id, delta: blocked });
|
|
@@ -161,7 +167,7 @@ export class TextDriver {
|
|
|
161
167
|
return resolveStructuredDecide(node, ctx, system);
|
|
162
168
|
}
|
|
163
169
|
async awaitUser(ctx) {
|
|
164
|
-
const input =
|
|
170
|
+
const input = consumeAllPendingUserInput(ctx.session) ?? '';
|
|
165
171
|
return { type: 'message', input };
|
|
166
172
|
}
|
|
167
173
|
resolveTools(resolved, ctx) {
|
|
@@ -53,6 +53,12 @@ export class VoiceDriver {
|
|
|
53
53
|
const preTurn = await applyPreTurnPolicies(ctx);
|
|
54
54
|
if (!preTurn.proceed) {
|
|
55
55
|
const blocked = preTurn.blockedMessage ?? 'Input blocked by guardrails';
|
|
56
|
+
ctx.emit({
|
|
57
|
+
type: 'safety-blocked',
|
|
58
|
+
moderator: preTurn.blockedBy ?? 'input-guardrails',
|
|
59
|
+
rationale: preTurn.blockedReason ?? 'input blocked',
|
|
60
|
+
userFacingMessage: blocked,
|
|
61
|
+
});
|
|
56
62
|
const id = crypto.randomUUID();
|
|
57
63
|
ctx.emit({ type: 'text-start', id });
|
|
58
64
|
ctx.emit({ type: 'text-delta', id, delta: blocked });
|
|
@@ -4,4 +4,4 @@ export type { TextDriverConfig } from './TextDriver.js';
|
|
|
4
4
|
export { VoiceDriver } from './VoiceDriver.js';
|
|
5
5
|
export type { VoiceDriverConfig } from './VoiceDriver.js';
|
|
6
6
|
export { resolveVoiceGeminiTools, v2ToolsToGemini } from './voiceTools.js';
|
|
7
|
-
export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
|
|
7
|
+
export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
|
|
@@ -5,4 +5,4 @@ export { TextDriver, buildNodePrompt } from './TextDriver.js';
|
|
|
5
5
|
// the primary primitive; cascaded voice runs over text (see livekit-plugin).
|
|
6
6
|
export { VoiceDriver } from './VoiceDriver.js';
|
|
7
7
|
export { resolveVoiceGeminiTools, v2ToolsToGemini } from './voiceTools.js';
|
|
8
|
-
export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
|
|
8
|
+
export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './inputBuffer.js';
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Session } from '../../types/session.js';
|
|
2
|
-
import type
|
|
2
|
+
import { type UserInputContent } from '../userInput.js';
|
|
3
3
|
export declare function setPendingUserInput(session: Session, input: UserInputContent): void;
|
|
4
|
+
/** Dequeue one pending input. Built-in drivers drain the full queue via {@link consumeAllPendingUserInput}. */
|
|
4
5
|
export declare function consumePendingUserInput(session: Session): UserInputContent;
|
|
6
|
+
/** Drain the pending-input FIFO and merge into one turn (mid-turn enqueue-merge). */
|
|
7
|
+
export declare function consumeAllPendingUserInput(session: Session): UserInputContent | undefined;
|
|
5
8
|
export declare function peekPendingUserInput(session: Session): UserInputContent | undefined;
|
|
6
9
|
export declare function hasPendingUserInput(session: Session): boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mergeUserInputContents } from '../userInput.js';
|
|
1
2
|
const PENDING_INPUT_KEY = '__v2_pendingUserInput';
|
|
2
3
|
function queue(session) {
|
|
3
4
|
const v = session.workingMemory[PENDING_INPUT_KEY];
|
|
@@ -10,6 +11,7 @@ function queue(session) {
|
|
|
10
11
|
export function setPendingUserInput(session, input) {
|
|
11
12
|
session.workingMemory[PENDING_INPUT_KEY] = [...queue(session), input];
|
|
12
13
|
}
|
|
14
|
+
/** Dequeue one pending input. Built-in drivers drain the full queue via {@link consumeAllPendingUserInput}. */
|
|
13
15
|
export function consumePendingUserInput(session) {
|
|
14
16
|
const q = queue(session);
|
|
15
17
|
const next = q.shift() ?? '';
|
|
@@ -19,6 +21,12 @@ export function consumePendingUserInput(session) {
|
|
|
19
21
|
session.workingMemory[PENDING_INPUT_KEY] = q;
|
|
20
22
|
return next;
|
|
21
23
|
}
|
|
24
|
+
/** Drain the pending-input FIFO and merge into one turn (mid-turn enqueue-merge). */
|
|
25
|
+
export function consumeAllPendingUserInput(session) {
|
|
26
|
+
const q = queue(session);
|
|
27
|
+
delete session.workingMemory[PENDING_INPUT_KEY];
|
|
28
|
+
return mergeUserInputContents(q);
|
|
29
|
+
}
|
|
22
30
|
export function peekPendingUserInput(session) {
|
|
23
31
|
return queue(session)[0];
|
|
24
32
|
}
|
package/dist/runtime/closeRun.js
CHANGED
|
@@ -22,5 +22,9 @@ export async function closeRun(options) {
|
|
|
22
22
|
const latest = (await sessionStore.get(session.id)) ?? session;
|
|
23
23
|
latest.currentAgent = runState.activeAgentId;
|
|
24
24
|
latest.activeAgentId = runState.activeAgentId;
|
|
25
|
+
// Sync the session-level message mirror to the canonical run record — it
|
|
26
|
+
// otherwise lacks assistant turns and keeps pre-guardrail (unredacted)
|
|
27
|
+
// user input written at openRun.
|
|
28
|
+
latest.messages = [...runState.messages];
|
|
25
29
|
await sessionStore.save(latest);
|
|
26
30
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type LanguageModel, type ModelMessage } from 'ai';
|
|
2
|
+
/**
|
|
3
|
+
* Automatic conversation-history compaction.
|
|
4
|
+
*
|
|
5
|
+
* Long-running sessions (weeks-long WhatsApp threads) grow unbounded; budget
|
|
6
|
+
* truncation silently loses the early relationship. Compaction summarizes the
|
|
7
|
+
* older history into one system note and keeps the recent tail verbatim, so
|
|
8
|
+
* the model retains facts/decisions without paying the full token cost.
|
|
9
|
+
*
|
|
10
|
+
* Runs post-turn (off the user's latency path) when the estimated history
|
|
11
|
+
* tokens exceed `triggerTokens`, and force-runs as the recovery step after a
|
|
12
|
+
* provider context-overflow error (see `contextOverflow.ts`).
|
|
13
|
+
*/
|
|
14
|
+
export interface CompactionConfig {
|
|
15
|
+
/** Summarizer model. Defaults to the active agent's controlModel/model. */
|
|
16
|
+
model?: LanguageModel;
|
|
17
|
+
/** Estimated history tokens that trigger compaction. Default: 8000. */
|
|
18
|
+
triggerTokens?: number;
|
|
19
|
+
/** Number of recent messages kept verbatim. Default: 12. */
|
|
20
|
+
keepRecentMessages?: number;
|
|
21
|
+
/** Override the summarizer system prompt. */
|
|
22
|
+
summaryPrompt?: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const DEFAULT_COMPACTION_TRIGGER_TOKENS = 8000;
|
|
25
|
+
export declare const DEFAULT_COMPACTION_KEEP_RECENT = 12;
|
|
26
|
+
export type CompactionResult = {
|
|
27
|
+
compacted: true;
|
|
28
|
+
messages: ModelMessage[];
|
|
29
|
+
beforeTokens: number;
|
|
30
|
+
afterTokens: number;
|
|
31
|
+
summarizedCount: number;
|
|
32
|
+
} | {
|
|
33
|
+
compacted: false;
|
|
34
|
+
reason: 'under-threshold' | 'too-few-messages' | 'summarizer-error';
|
|
35
|
+
beforeTokens: number;
|
|
36
|
+
};
|
|
37
|
+
export declare function estimateMessagesTokens(messages: ModelMessage[]): number;
|
|
38
|
+
export interface CompactMessagesOptions {
|
|
39
|
+
messages: ModelMessage[];
|
|
40
|
+
model: LanguageModel;
|
|
41
|
+
config: CompactionConfig;
|
|
42
|
+
/** Skip the threshold check (overflow recovery). */
|
|
43
|
+
force?: boolean;
|
|
44
|
+
abortSignal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Pure compaction step: returns a new message list when compaction applied.
|
|
48
|
+
* The kept tail always starts at a `user` message so assistant/tool-call
|
|
49
|
+
* pairs are never split across the summary boundary.
|
|
50
|
+
*/
|
|
51
|
+
export declare function compactMessages(options: CompactMessagesOptions): Promise<CompactionResult>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import { estimateTokenCount } from './ContextBudget.js';
|
|
3
|
+
export const DEFAULT_COMPACTION_TRIGGER_TOKENS = 8_000;
|
|
4
|
+
export const DEFAULT_COMPACTION_KEEP_RECENT = 12;
|
|
5
|
+
const DEFAULT_SUMMARY_PROMPT = [
|
|
6
|
+
'Summarize this conversation so an assistant can continue it seamlessly.',
|
|
7
|
+
'Preserve, with exact values: user identity details shared (name, address, contact),',
|
|
8
|
+
'stable preferences, decisions made, actions COMPLETED (orders, bookings, tool actions',
|
|
9
|
+
'with their result ids/amounts), and open questions or pending steps.',
|
|
10
|
+
'Do not invent anything. Maximum 250 words.',
|
|
11
|
+
].join(' ');
|
|
12
|
+
export function estimateMessagesTokens(messages) {
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (const message of messages) {
|
|
15
|
+
total += estimateTokenCount(typeof message.content === 'string' ? message.content : safeStringify(message.content));
|
|
16
|
+
}
|
|
17
|
+
return total;
|
|
18
|
+
}
|
|
19
|
+
function safeStringify(value) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(value) ?? '';
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return String(value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Text projection of messages for the summarizer (tool calls become bracketed notes). */
|
|
28
|
+
function renderTranscript(messages) {
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const message of messages) {
|
|
31
|
+
if (typeof message.content === 'string') {
|
|
32
|
+
lines.push(`${message.role}: ${message.content}`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!Array.isArray(message.content))
|
|
36
|
+
continue;
|
|
37
|
+
const parts = [];
|
|
38
|
+
for (const part of message.content) {
|
|
39
|
+
if (part.type === 'text' && typeof part.text === 'string') {
|
|
40
|
+
parts.push(part.text);
|
|
41
|
+
}
|
|
42
|
+
else if (part.type === 'tool-call') {
|
|
43
|
+
parts.push(`[called ${String(part.toolName)} with ${truncate(safeStringify(part.input ?? part.args), 200)}]`);
|
|
44
|
+
}
|
|
45
|
+
else if (part.type === 'tool-result') {
|
|
46
|
+
parts.push(`[${String(part.toolName)} returned ${truncate(safeStringify(part.output ?? part.result), 200)}]`);
|
|
47
|
+
}
|
|
48
|
+
else if (part.type === 'file' || part.type === 'image') {
|
|
49
|
+
parts.push(`[${String(part.type)} attachment]`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (parts.length > 0) {
|
|
53
|
+
lines.push(`${message.role}: ${parts.join(' ')}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
function truncate(text, max) {
|
|
59
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Pure compaction step: returns a new message list when compaction applied.
|
|
63
|
+
* The kept tail always starts at a `user` message so assistant/tool-call
|
|
64
|
+
* pairs are never split across the summary boundary.
|
|
65
|
+
*/
|
|
66
|
+
export async function compactMessages(options) {
|
|
67
|
+
const { messages, model, config, force, abortSignal } = options;
|
|
68
|
+
const triggerTokens = config.triggerTokens ?? DEFAULT_COMPACTION_TRIGGER_TOKENS;
|
|
69
|
+
const keepRecent = config.keepRecentMessages ?? DEFAULT_COMPACTION_KEEP_RECENT;
|
|
70
|
+
const beforeTokens = estimateMessagesTokens(messages);
|
|
71
|
+
if (!force && beforeTokens < triggerTokens) {
|
|
72
|
+
return { compacted: false, reason: 'under-threshold', beforeTokens };
|
|
73
|
+
}
|
|
74
|
+
let cut = Math.max(messages.length - keepRecent, 0);
|
|
75
|
+
while (cut > 0 && messages[cut]?.role !== 'user') {
|
|
76
|
+
cut -= 1;
|
|
77
|
+
}
|
|
78
|
+
if (cut < 2) {
|
|
79
|
+
return { compacted: false, reason: 'too-few-messages', beforeTokens };
|
|
80
|
+
}
|
|
81
|
+
const older = messages.slice(0, cut);
|
|
82
|
+
const transcript = renderTranscript(older);
|
|
83
|
+
let summary;
|
|
84
|
+
try {
|
|
85
|
+
const result = await generateText({
|
|
86
|
+
model,
|
|
87
|
+
system: config.summaryPrompt ?? DEFAULT_SUMMARY_PROMPT,
|
|
88
|
+
prompt: transcript,
|
|
89
|
+
abortSignal,
|
|
90
|
+
});
|
|
91
|
+
summary = result.text.trim();
|
|
92
|
+
if (!summary) {
|
|
93
|
+
return { compacted: false, reason: 'summarizer-error', beforeTokens };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { compacted: false, reason: 'summarizer-error', beforeTokens };
|
|
98
|
+
}
|
|
99
|
+
const summaryMessage = {
|
|
100
|
+
role: 'system',
|
|
101
|
+
content: `[Conversation summary — earlier turns were compacted]\n${summary}`,
|
|
102
|
+
};
|
|
103
|
+
const compacted = [summaryMessage, ...messages.slice(cut)];
|
|
104
|
+
return {
|
|
105
|
+
compacted: true,
|
|
106
|
+
messages: compacted,
|
|
107
|
+
beforeTokens,
|
|
108
|
+
afterTokens: estimateMessagesTokens(compacted),
|
|
109
|
+
summarizedCount: older.length,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EscalationReason } from '../escalation/types.js';
|
|
1
2
|
import type { AgentConfig } from '../types/agentConfig.js';
|
|
2
3
|
import type { ChannelDriver } from '../types/channel.js';
|
|
3
4
|
import type { RunContext } from '../types/run-context.js';
|
|
@@ -8,6 +9,7 @@ export type HostLoopResult = {
|
|
|
8
9
|
kind: 'handoff';
|
|
9
10
|
to: string;
|
|
10
11
|
reason?: string;
|
|
12
|
+
category?: EscalationReason;
|
|
11
13
|
} | {
|
|
12
14
|
kind: 'ended';
|
|
13
15
|
reason: string;
|
package/dist/runtime/hostLoop.js
CHANGED
|
@@ -183,7 +183,7 @@ async function executeHostControl(agent, run, driver, ctx, control) {
|
|
|
183
183
|
}
|
|
184
184
|
if (control.type === 'escalate') {
|
|
185
185
|
ctx.emit({ type: 'handoff', targetAgent: 'human', reason: control.reason });
|
|
186
|
-
return { kind: 'handoff', to: 'human', reason: control.reason };
|
|
186
|
+
return { kind: 'handoff', to: 'human', reason: control.reason, category: control.category };
|
|
187
187
|
}
|
|
188
188
|
if (control.type === 'recover') {
|
|
189
189
|
return { kind: 'ended', reason: control.reason ?? 'error_degraded' };
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export type { RuntimeLike } from './RuntimeLike.js';
|
|
|
3
3
|
export { SessionWorkingMemory } from './WorkingMemory.js';
|
|
4
4
|
export { TextDriver, VoiceDriver } from './channels/index.js';
|
|
5
5
|
export type { VoiceDriverConfig } from './channels/index.js';
|
|
6
|
-
export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
|
|
7
|
-
export { userInputToText, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './userInput.js';
|
|
6
|
+
export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
|
|
7
|
+
export { userInputToText, mergeUserInputContents, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './userInput.js';
|
|
8
8
|
export { isAbortSignal, type InterruptionEvent, type AbortOptions, type CancellationReason, } from '../types/index.js';
|
package/dist/runtime/index.js
CHANGED
|
@@ -4,9 +4,9 @@ export { TextDriver, VoiceDriver } from './channels/index.js';
|
|
|
4
4
|
// Pending-input buffer helpers — required by custom ChannelDriver authors to
|
|
5
5
|
// implement awaitUser the same FIFO-aware way the built-in drivers do (the
|
|
6
6
|
// buffer is an ordered queue since 0.3.13/H3, not a single slot).
|
|
7
|
-
export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
|
|
7
|
+
export { setPendingUserInput, consumePendingUserInput, consumeAllPendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
|
|
8
8
|
// Multimodal user input — `UserInputContent` is the runtime's accepted user-turn
|
|
9
9
|
// shape (text or AI SDK file/image/audio parts). Helpers project to text and
|
|
10
10
|
// transcribe audio so ingress adapters (web/messaging) can build it uniformly.
|
|
11
|
-
export { userInputToText, hasMediaParts, transcribeAudioParts, } from './userInput.js';
|
|
11
|
+
export { userInputToText, mergeUserInputContents, hasMediaParts, transcribeAudioParts, } from './userInput.js';
|
|
12
12
|
export { isAbortSignal, } from '../types/index.js';
|