@kuralle-agents/core 0.7.2 → 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.
Files changed (49) hide show
  1. package/README.md +6 -0
  2. package/dist/capabilities/validators/groundingValidator.d.ts +21 -0
  3. package/dist/capabilities/validators/groundingValidator.js +109 -0
  4. package/dist/escalation/escalation.d.ts +20 -0
  5. package/dist/escalation/escalation.js +85 -0
  6. package/dist/escalation/types.d.ts +40 -0
  7. package/dist/eval/simulation.d.ts +99 -0
  8. package/dist/eval/simulation.js +168 -0
  9. package/dist/flow/collectUntilComplete.js +5 -2
  10. package/dist/flow/runFlow.js +6 -3
  11. package/dist/index.d.ts +25 -3
  12. package/dist/index.js +12 -0
  13. package/dist/memory/factMemoryService.d.ts +28 -0
  14. package/dist/memory/factMemoryService.js +137 -0
  15. package/dist/memory/index.d.ts +1 -0
  16. package/dist/memory/index.js +1 -0
  17. package/dist/processors/builtin/moderationGuard.d.ts +24 -0
  18. package/dist/processors/builtin/moderationGuard.js +101 -0
  19. package/dist/processors/builtin/piiGuard.d.ts +41 -0
  20. package/dist/processors/builtin/piiGuard.js +143 -0
  21. package/dist/processors/builtin/promptInjectionGuard.d.ts +16 -0
  22. package/dist/processors/builtin/promptInjectionGuard.js +28 -0
  23. package/dist/runtime/Runtime.d.ts +63 -2
  24. package/dist/runtime/Runtime.js +185 -10
  25. package/dist/runtime/channels/TextDriver.js +6 -0
  26. package/dist/runtime/channels/VoiceDriver.js +6 -0
  27. package/dist/runtime/channels/inputBuffer.d.ts +4 -3
  28. package/dist/runtime/closeRun.js +4 -0
  29. package/dist/runtime/compaction.d.ts +51 -0
  30. package/dist/runtime/compaction.js +111 -0
  31. package/dist/runtime/ctx.js +9 -0
  32. package/dist/runtime/hostLoop.d.ts +2 -0
  33. package/dist/runtime/hostLoop.js +7 -1
  34. package/dist/runtime/index.d.ts +1 -0
  35. package/dist/runtime/index.js +4 -0
  36. package/dist/runtime/openRun.d.ts +9 -2
  37. package/dist/runtime/openRun.js +26 -2
  38. package/dist/runtime/policies/agentTurn.d.ts +4 -0
  39. package/dist/runtime/policies/agentTurn.js +35 -4
  40. package/dist/runtime/userInput.d.ts +24 -0
  41. package/dist/runtime/userInput.js +67 -0
  42. package/dist/scheduler/index.d.ts +89 -0
  43. package/dist/scheduler/index.js +104 -0
  44. package/dist/testing/mocks.d.ts +2 -1
  45. package/dist/types/channel.d.ts +4 -1
  46. package/dist/types/run-context.d.ts +4 -0
  47. package/dist/types/stream.d.ts +29 -0
  48. package/guides/GUARDRAILS.md +44 -0
  49. package/package.json +2 -2
@@ -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
+ }
@@ -1,4 +1,5 @@
1
- import type { LanguageModel, ModelMessage } from 'ai';
1
+ import type { LanguageModel, ModelMessage, TranscriptionModel } from 'ai';
2
+ import type { UserInputContent } from './userInput.js';
2
3
  import type { Session } from '../types/session.js';
3
4
  import type { SessionStore } from '../session/SessionStore.js';
4
5
  import type { AuditListOptions, ConversationAuditEntry } from '../audit/types.js';
@@ -14,6 +15,9 @@ import type { classifyHostTarget, selectHostTarget } from './select.js';
14
15
  import type { KnowledgeProviderConfig } from '../types/voice.js';
15
16
  import type { MemoryService as V1MemoryService } from '../memory/MemoryService.js';
16
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';
17
21
  export interface HarnessConfig {
18
22
  agents: AgentConfig[];
19
23
  defaultAgentId: string;
@@ -31,11 +35,40 @@ export interface HarnessConfig {
31
35
  memoryService?: V1MemoryService;
32
36
  /** Default store for `agent.memory.workingMemory` when `workingMemory.store` is omitted. */
33
37
  defaultWorkingMemoryStore?: PersistentMemoryStore;
38
+ /**
39
+ * Optional AI SDK transcription model. When set, inbound audio file parts (voice
40
+ * notes) are transcribed to text before the model turn — so voice input works on
41
+ * text-only models. When omitted, audio parts pass through to audio-capable models.
42
+ */
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;
34
59
  }
35
60
  export interface RunOptions {
36
61
  sessionId?: string;
37
- input?: string;
62
+ /** The user turn: plain text, or AI SDK multimodal content (text + file/image/audio parts). */
63
+ input?: UserInputContent;
38
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;
39
72
  userId?: string;
40
73
  agentId?: string;
41
74
  seedMessages?: ModelMessage[];
@@ -66,6 +99,34 @@ export declare class Runtime {
66
99
  reason?: string;
67
100
  markedBy?: ConversationOutcomeMarkedBy;
68
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;
69
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>;
70
131
  }
71
132
  export declare function createRuntime(config: HarnessConfig): Runtime;
@@ -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,10 +62,12 @@ 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,
62
69
  signalDelivery: opts.signalDelivery,
70
+ transcriptionModel: this.config.transcriptionModel,
63
71
  defaultAgentId: this.config.defaultAgentId,
64
72
  sessionStore: this.sessionStore,
65
73
  });
@@ -120,26 +128,41 @@ export class Runtime {
120
128
  runCtx.workingMemoryPrompt = openingSurface.workingMemoryPrompt;
121
129
  runCtx.workingMemoryTools = openingSurface.workingMemoryTools;
122
130
  await this.hooks?.onStart?.(runCtx);
131
+ if (opts.wake) {
132
+ emit({ type: 'wake', reason: opts.wake.reason });
133
+ }
123
134
  const driver = opts.driver ?? new TextDriver();
124
135
  let activeAgent = opened.agent;
125
136
  let loopResult = { kind: 'turnComplete' };
126
137
  let handoffCount = 0;
127
138
  let terminalOutcome;
139
+ let overflowRetried = false;
128
140
  try {
129
- for (;;) {
130
- loopResult = await hostLoop({
131
- agent: activeAgent,
132
- run: runCtx.runState,
133
- driver,
134
- ctx: runCtx,
135
- classify: this.config.hostClassify ??
136
- (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
137
- });
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
+ }
138
160
  if (loopResult.kind === 'handoff') {
139
161
  if (this.terminalHandoffTargets.has(loopResult.to)) {
140
162
  emit({ type: 'handoff', targetAgent: loopResult.to, reason: loopResult.reason });
141
163
  runCtx.runState.status = 'paused';
142
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 });
143
166
  break;
144
167
  }
145
168
  handoffCount += 1;
@@ -182,10 +205,20 @@ export class Runtime {
182
205
  break;
183
206
  }
184
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
+ }
185
215
  break;
186
216
  }
187
217
  break;
188
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);
189
222
  }
190
223
  catch (error) {
191
224
  await this.hooks?.onError?.(runCtx, error);
@@ -275,11 +308,153 @@ export class Runtime {
275
308
  markedBy: opts?.markedBy ?? 'http',
276
309
  });
277
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
+ }
278
370
  async getConversationLength(sessionId) {
279
371
  const runStore = new SessionRunStore(this.sessionStore, sessionId);
280
372
  const runState = await runStore.getRunState(sessionId);
281
373
  return runState?.messages.length ?? 0;
282
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
+ }
283
458
  }
284
459
  export function createRuntime(config) {
285
460
  return new Runtime(config);
@@ -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 });
@@ -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 });
@@ -1,5 +1,6 @@
1
1
  import type { Session } from '../../types/session.js';
2
- export declare function setPendingUserInput(session: Session, input: string): void;
3
- export declare function consumePendingUserInput(session: Session): string;
4
- export declare function peekPendingUserInput(session: Session): string | undefined;
2
+ import type { UserInputContent } from '../userInput.js';
3
+ export declare function setPendingUserInput(session: Session, input: UserInputContent): void;
4
+ export declare function consumePendingUserInput(session: Session): UserInputContent;
5
+ export declare function peekPendingUserInput(session: Session): UserInputContent | undefined;
5
6
  export declare function hasPendingUserInput(session: Session): boolean;
@@ -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
+ }
@@ -112,6 +112,15 @@ function makeCtx(deps) {
112
112
  bargeIn: deps.bargeIn,
113
113
  abortSignal: deps.abortSignal,
114
114
  turnInputConsumed: false,
115
+ // Rebase durable effect callsites to 0. Called at flow entry so a flow's
116
+ // effects (and any suspend/resume callsite) are anchored to the flow itself —
117
+ // identical whether the flow was entered fresh after an answering turn (which
118
+ // may have consumed callsites via enter_flow / tool calls) or re-entered on
119
+ // resume (where that answering turn does not re-run). Without this, a suspend's
120
+ // recorded callsite would not match on resume and the run would re-suspend.
121
+ resetCallsites: () => {
122
+ effectOrdinal = 0;
123
+ },
115
124
  tool: async (name, args, options) => {
116
125
  // needsApproval gate: a tool flagged `needsApproval` must be approved by a human
117
126
  // before it runs. Approval is a durable pause (the `__approval` signal); on resume
@@ -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;
@@ -59,6 +59,12 @@ async function runAnsweringAgent(agent, run, driver, ctx, classify) {
59
59
  async function runActiveFlow(flow, run, driver, ctx, agent) {
60
60
  incrementTurnCount(run);
61
61
  assertWithinTurnLimit(run, ctx.limits);
62
+ // Anchor durable effect callsites to the flow. On a fresh entry the answering
63
+ // turn may have consumed callsites (enter_flow / tool calls); on resume that
64
+ // turn does not re-run. Rebasing here makes the flow's callsites (and any
65
+ // suspend/resume key) identical across both paths, so a resumed run does not
66
+ // re-suspend on a callsite mismatch.
67
+ ctx.resetCallsites();
62
68
  const result = await runFlow(flow, run, driver, ctx, agent);
63
69
  if (result.kind === 'handoff') {
64
70
  return { kind: 'handoff', to: result.to, reason: result.reason };
@@ -177,7 +183,7 @@ async function executeHostControl(agent, run, driver, ctx, control) {
177
183
  }
178
184
  if (control.type === 'escalate') {
179
185
  ctx.emit({ type: 'handoff', targetAgent: 'human', reason: control.reason });
180
- return { kind: 'handoff', to: 'human', reason: control.reason };
186
+ return { kind: 'handoff', to: 'human', reason: control.reason, category: control.category };
181
187
  }
182
188
  if (control.type === 'recover') {
183
189
  return { kind: 'ended', reason: control.reason ?? 'error_degraded' };
@@ -4,4 +4,5 @@ export { SessionWorkingMemory } from './WorkingMemory.js';
4
4
  export { TextDriver, VoiceDriver } from './channels/index.js';
5
5
  export type { VoiceDriverConfig } from './channels/index.js';
6
6
  export { setPendingUserInput, consumePendingUserInput, peekPendingUserInput, hasPendingUserInput, } from './channels/index.js';
7
+ export { userInputToText, hasMediaParts, transcribeAudioParts, type UserInputContent, } from './userInput.js';
7
8
  export { isAbortSignal, type InterruptionEvent, type AbortOptions, type CancellationReason, } from '../types/index.js';