@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.
Files changed (39) 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/index.d.ts +22 -2
  10. package/dist/index.js +10 -0
  11. package/dist/memory/factMemoryService.d.ts +28 -0
  12. package/dist/memory/factMemoryService.js +137 -0
  13. package/dist/memory/index.d.ts +1 -0
  14. package/dist/memory/index.js +1 -0
  15. package/dist/processors/builtin/moderationGuard.d.ts +24 -0
  16. package/dist/processors/builtin/moderationGuard.js +101 -0
  17. package/dist/processors/builtin/piiGuard.d.ts +41 -0
  18. package/dist/processors/builtin/piiGuard.js +143 -0
  19. package/dist/processors/builtin/promptInjectionGuard.d.ts +16 -0
  20. package/dist/processors/builtin/promptInjectionGuard.js +28 -0
  21. package/dist/runtime/Runtime.d.ts +53 -0
  22. package/dist/runtime/Runtime.js +184 -10
  23. package/dist/runtime/channels/TextDriver.js +6 -0
  24. package/dist/runtime/channels/VoiceDriver.js +6 -0
  25. package/dist/runtime/closeRun.js +4 -0
  26. package/dist/runtime/compaction.d.ts +51 -0
  27. package/dist/runtime/compaction.js +111 -0
  28. package/dist/runtime/hostLoop.d.ts +2 -0
  29. package/dist/runtime/hostLoop.js +1 -1
  30. package/dist/runtime/openRun.d.ts +5 -0
  31. package/dist/runtime/openRun.js +17 -0
  32. package/dist/runtime/policies/agentTurn.d.ts +4 -0
  33. package/dist/runtime/policies/agentTurn.js +35 -4
  34. package/dist/scheduler/index.d.ts +89 -0
  35. package/dist/scheduler/index.js +104 -0
  36. package/dist/types/channel.d.ts +2 -0
  37. package/dist/types/stream.d.ts +29 -0
  38. package/guides/GUARDRAILS.md +44 -0
  39. package/package.json +2 -2
@@ -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
- loopResult = await hostLoop({
132
- agent: activeAgent,
133
- run: runCtx.runState,
134
- driver,
135
- ctx: runCtx,
136
- classify: this.config.hostClassify ??
137
- (this.config.hostSelect ? adaptHostSelect(this.config.hostSelect) : undefined),
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);
@@ -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 });
@@ -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;
@@ -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' };
@@ -12,6 +12,11 @@ export interface OpenRunOptions {
12
12
  userId?: string;
13
13
  input?: UserInputContent;
14
14
  selection?: ResolvedSelection;
15
+ /** Agent-initiated turn: no user input; a wake note is appended instead. */
16
+ wake?: {
17
+ reason: string;
18
+ payload?: Record<string, unknown>;
19
+ };
15
20
  agentId?: string;
16
21
  seedMessages?: ModelMessage[];
17
22
  historyDelta?: ModelMessage[];
@@ -71,6 +71,23 @@ export async function openRun(agentsById, options) {
71
71
  await options.sessionStore.save(sessionAfterPersist);
72
72
  }
73
73
  }
74
+ if (options.wake && !hasInput) {
75
+ const payloadNote = options.wake.payload
76
+ ? ` Context: ${JSON.stringify(options.wake.payload)}.`
77
+ : '';
78
+ const wakeMessage = {
79
+ role: 'system',
80
+ content: `[Scheduled wake: ${options.wake.reason}]${payloadNote} ` +
81
+ 'There is no new user message. Re-engage the user proactively per your instructions; ' +
82
+ 'if a task is in progress, follow up on it gently.',
83
+ };
84
+ runState.messages = [...runState.messages, wakeMessage];
85
+ runState.updatedAt = Date.now();
86
+ await runStore.putRunState(runState);
87
+ const sessionAfterPersist = (await options.sessionStore.get(options.sessionId)) ?? session;
88
+ sessionAfterPersist.messages = [...sessionAfterPersist.messages, wakeMessage];
89
+ await options.sessionStore.save(sessionAfterPersist);
90
+ }
74
91
  const agent = agentsById.get(runState.activeAgentId);
75
92
  if (!agent) {
76
93
  throw new Error(`Unknown activeAgentId "${runState.activeAgentId}"`);
@@ -6,6 +6,10 @@ export interface PreTurnResult {
6
6
  proceed: boolean;
7
7
  userMessage: string;
8
8
  blockedMessage?: string;
9
+ /** Id of the processor/policy that blocked (set when `proceed` is false). */
10
+ blockedBy?: string;
11
+ /** Machine-readable block reason (set when `proceed` is false). */
12
+ blockedReason?: string;
9
13
  }
10
14
  export interface PostTurnResult {
11
15
  proceed: boolean;
@@ -1,11 +1,24 @@
1
1
  import { appendConversationAudit } from '../../audit/record.js';
2
2
  import { SAFE_DEGRADED_MESSAGE } from '../../flow/degrade.js';
3
3
  import { runInputProcessors, runOutputProcessors } from '../../processors/ProcessorRunner.js';
4
+ function userTextProjection(content) {
5
+ if (typeof content === 'string')
6
+ return content;
7
+ if (!Array.isArray(content))
8
+ return '';
9
+ return content
10
+ .filter((part) => part.type === 'text' && typeof part.text === 'string')
11
+ .map((part) => part.text)
12
+ .join('\n');
13
+ }
14
+ // Guards must see multimodal turns too: a WhatsApp image whose CAPTION carries
15
+ // a card number is still PII; an injection attempt in a caption is still an
16
+ // injection. Project the text parts — file/image parts pass through untouched.
4
17
  function latestUserMessage(messages) {
5
18
  for (let index = messages.length - 1; index >= 0; index -= 1) {
6
19
  const message = messages[index];
7
- if (message?.role === 'user' && typeof message.content === 'string') {
8
- return message.content;
20
+ if (message?.role === 'user') {
21
+ return userTextProjection(message.content);
9
22
  }
10
23
  }
11
24
  return '';
@@ -91,6 +104,8 @@ async function runRefinementPolicies(ctx, userMessage) {
91
104
  proceed: false,
92
105
  userMessage: current,
93
106
  blockedMessage: decision.userFacingMessage ?? decision.rationale ?? 'Input blocked',
107
+ blockedBy: policy.name,
108
+ blockedReason: decision.rationale,
94
109
  };
95
110
  }
96
111
  if (decision.decision === 'rewrite') {
@@ -138,7 +153,7 @@ async function runValidationPolicies(ctx, userMessage, assistantOutput, toolCall
138
153
  proceed: false,
139
154
  text: safe,
140
155
  blockedMessage: safe,
141
- control: { type: 'escalate', reason: decision.rationale },
156
+ control: { type: 'escalate', reason: decision.rationale, category: decision.escalationReason },
142
157
  confidence: decision.confidence,
143
158
  };
144
159
  }
@@ -170,10 +185,16 @@ export async function applyPreTurnPolicies(ctx) {
170
185
  proceed: false,
171
186
  userMessage,
172
187
  blockedMessage: outcome.message,
188
+ blockedBy: outcome.processorId,
189
+ blockedReason: outcome.reason,
173
190
  };
174
191
  }
175
192
  if (outcome.input !== userMessage) {
176
193
  patchLatestUserMessage(ctx.runState.messages, outcome.input);
194
+ patchLatestUserMessage(ctx.session.messages, outcome.input);
195
+ // Persist immediately: a redaction (e.g. PII) must replace the raw
196
+ // value in the durable record, not only in this turn's memory.
197
+ await ctx.runStore.putRunState(ctx.runState);
177
198
  }
178
199
  }
179
200
  return runRefinementPolicies(ctx, latestUserMessage(ctx.runState.messages));
@@ -210,7 +231,17 @@ function patchLatestUserMessage(messages, next) {
210
231
  for (let index = messages.length - 1; index >= 0; index -= 1) {
211
232
  const message = messages[index];
212
233
  if (message?.role === 'user') {
213
- messages[index] = { role: 'user', content: next };
234
+ if (Array.isArray(message.content)) {
235
+ // Multimodal turn: rewrite the text while preserving file/image parts.
236
+ const mediaParts = message.content.filter((part) => part.type !== 'text');
237
+ messages[index] = {
238
+ role: 'user',
239
+ content: [{ type: 'text', text: next }, ...mediaParts],
240
+ };
241
+ }
242
+ else {
243
+ messages[index] = { role: 'user', content: next };
244
+ }
214
245
  return;
215
246
  }
216
247
  }