@kuralle-agents/cf-agent 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.
@@ -1,3 +1,4 @@
1
+ import { DURABLE_RUNS_KEY } from '@kuralle-agents/core';
1
2
  import { getToolName, isToolUIPart } from 'ai';
2
3
  import { OrchestrationStore } from './OrchestrationStore.js';
3
4
  /**
@@ -32,7 +33,7 @@ export class BridgeSessionStore {
32
33
  const orchState = await this.orchestration.get(key);
33
34
  // Convert CF UIMessages to Kuralle ModelMessages
34
35
  const messages = convertUIMessagesToModelMessages(this.cfMessages);
35
- return {
36
+ const session = {
36
37
  id: key,
37
38
  conversationId: key,
38
39
  channelId: 'web',
@@ -48,6 +49,10 @@ export class BridgeSessionStore {
48
49
  })),
49
50
  state: orchState?.state,
50
51
  };
52
+ // Restore the durable run journal so durable tools / suspend-resume can find
53
+ // the run (SessionRunStore reads it off the Session object).
54
+ session[DURABLE_RUNS_KEY] = orchState?.durableRuns ?? {};
55
+ return session;
51
56
  }
52
57
  /**
53
58
  * Save only orchestration state. CF handles message persistence.
@@ -66,6 +71,7 @@ export class BridgeSessionStore {
66
71
  : String(h.timestamp),
67
72
  })),
68
73
  state: session.state,
74
+ durableRuns: session[DURABLE_RUNS_KEY],
69
75
  };
70
76
  await this.orchestration.save(session.id, state);
71
77
  }
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { AIChatAgent } from '@cloudflare/ai-chat';
22
22
  import { type HarnessConfig } from '@kuralle-agents/core';
23
- import type { PersistentMemoryStore } from '@kuralle-agents/core';
23
+ import type { PersistentMemoryStore, SignalDelivery, ScheduledJob, Scheduler, WakeJobPayload } from '@kuralle-agents/core';
24
24
  import type { StreamTextOnFinishCallback, ToolSet } from 'ai';
25
25
  import type { OnChatMessageOptions } from '@cloudflare/ai-chat';
26
26
  import type { StreamAdapterConfig } from './types.js';
@@ -79,6 +79,12 @@ export declare abstract class KuralleAgent<Env = unknown, State = unknown> exten
79
79
  * Get the Durable Object ID as the session identifier.
80
80
  */
81
81
  private getSessionId;
82
+ /**
83
+ * The hex Durable Object id for this instance. Subclasses use it to mint
84
+ * out-of-band callbacks (e.g. a payment link) that route back to this exact DO
85
+ * via `namespace.idFromString(...)`, then resume it through `resumeWithSignal`.
86
+ */
87
+ protected getDurableObjectId(): string;
82
88
  /**
83
89
  * Called by CF when a chat message arrives.
84
90
  *
@@ -102,7 +108,29 @@ export declare abstract class KuralleAgent<Env = unknown, State = unknown> exten
102
108
  */
103
109
  onChatMessage(onFinish: StreamTextOnFinishCallback<ToolSet>, options?: OnChatMessageOptions): Promise<Response>;
104
110
  /**
105
- * Extract the text content of the last user message from CF's messages array.
111
+ * Build a Kuralle runtime for this DO (fresh per request to pick up latest
112
+ * config). Bridges CF messages + DO-backed orchestration/working-memory state.
113
+ * Shared by the chat path and the durable resume path.
114
+ */
115
+ private buildRuntime;
116
+ /**
117
+ * Resume a suspended run by delivering a durable signal — the server-side
118
+ * counterpart to a human/out-of-band event (e.g. a paid checkout link being
119
+ * hit). Drives the resumed turn to completion, then persists **and broadcasts**
120
+ * the resumed assistant reply through CF's machinery, so a live client sees it
121
+ * and a reconnecting client replays it from history.
122
+ *
123
+ * Idempotent at the durable layer: delivering the same `signal.signalId` twice
124
+ * is deduplicated by the effect log, so a double-clicked link is safe.
125
+ *
126
+ * @returns the assistant text produced by the resumed turn (may be empty).
127
+ */
128
+ protected resumeWithSignal(signal: SignalDelivery): Promise<{
129
+ text: string;
130
+ }>;
131
+ /**
132
+ * Extract the last user turn from CF's messages as runtime input (multimodal).
133
+ * See `lastUserInputFromMessages`.
106
134
  */
107
135
  private getLastUserInput;
108
136
  /**
@@ -122,6 +150,31 @@ export declare abstract class KuralleAgent<Env = unknown, State = unknown> exten
122
150
  * ```
123
151
  */
124
152
  protected cleanupOrchestrationRows(maxAgeMs: number): Promise<number>;
153
+ /**
154
+ * Durable scheduler backed by the agents SDK's DO-alarm scheduling
155
+ * (`this.schedule`). Jobs survive isolate restarts and fire in this exact
156
+ * DO via `runScheduledKuralleJob`. Satisfies the core `Scheduler` contract,
157
+ * so engagement drips/broadcasts and runtime wake turns can share it.
158
+ */
159
+ protected wakeScheduler(): Scheduler;
160
+ /**
161
+ * Schedule a proactive wake turn for this DO's conversation (cart
162
+ * abandonment nudge, "check back in an hour", delivery follow-up).
163
+ * Returns the schedule id (cancellable via `wakeScheduler().cancel`).
164
+ */
165
+ protected scheduleWake(delayMs: number, wake: Omit<WakeJobPayload, 'sessionId'>): Promise<string>;
166
+ /**
167
+ * DO-alarm callback for scheduled jobs. Wake jobs run an agent-initiated
168
+ * turn and persist + broadcast the assistant reply through CF's machinery
169
+ * (same path as `resumeWithSignal`); other job kinds go to
170
+ * `onScheduledJob` for subclasses to handle.
171
+ */
172
+ runScheduledKuralleJob(job: ScheduledJob): Promise<void>;
173
+ /**
174
+ * Override to handle non-wake scheduled jobs (engagement drips, cleanup…).
175
+ * Default: no-op with a warning, so a mis-routed job is visible.
176
+ */
177
+ protected onScheduledJob(job: ScheduledJob): Promise<void>;
125
178
  /**
126
179
  * HTTP endpoint handler.
127
180
  * Adds Kuralle-specific endpoints on top of CF's defaults.
@@ -19,13 +19,14 @@
19
19
  * is stored in a separate lightweight SQLite table via OrchestrationStore.
20
20
  */
21
21
  import { AIChatAgent } from '@cloudflare/ai-chat';
22
- import { createRuntime } from '@kuralle-agents/core';
22
+ import { createRuntime, isWakeJob, wakeJob } from '@kuralle-agents/core';
23
23
  import { BridgeSessionStore } from './BridgeSessionStore.js';
24
24
  import { OrchestrationStore } from './OrchestrationStore.js';
25
25
  import { SqlPersistentMemoryStore } from './SqlPersistentMemoryStore.js';
26
26
  import { createSSEResponse } from './StreamAdapter.js';
27
27
  import { DEFAULT_STREAM_CONFIG } from './types.js';
28
28
  import { durableAgentSurface } from './durable-agent-surface.js';
29
+ import { lastUserInputFromMessages } from './cfMessageInput.js';
29
30
  /**
30
31
  * Abstract base class for running Kuralle agents on Cloudflare.
31
32
  *
@@ -83,6 +84,14 @@ export class KuralleAgent extends AIChatAgent {
83
84
  getSessionId() {
84
85
  return durableAgentSurface(this).ctx.id.toString();
85
86
  }
87
+ /**
88
+ * The hex Durable Object id for this instance. Subclasses use it to mint
89
+ * out-of-band callbacks (e.g. a payment link) that route back to this exact DO
90
+ * via `namespace.idFromString(...)`, then resume it through `resumeWithSignal`.
91
+ */
92
+ getDurableObjectId() {
93
+ return this.getSessionId();
94
+ }
86
95
  /**
87
96
  * Called by CF when a chat message arrives.
88
97
  *
@@ -110,19 +119,42 @@ export class KuralleAgent extends AIChatAgent {
110
119
  if (!lastUserMessage) {
111
120
  return new Response('No user message', { status: 400 });
112
121
  }
122
+ const sessionId = this.getSessionId();
123
+ this.runtime = this.buildRuntime();
124
+ const handle = this.runtime.run({
125
+ input: lastUserMessage,
126
+ sessionId,
127
+ userId: options?.body?.userId,
128
+ abortSignal: options?.abortSignal,
129
+ });
130
+ const streamConfig = {
131
+ ...DEFAULT_STREAM_CONFIG,
132
+ ...this.getStreamConfig(),
133
+ };
134
+ async function* parts() {
135
+ for await (const part of handle.events) {
136
+ yield part;
137
+ }
138
+ }
139
+ return createSSEResponse(parts(), streamConfig);
140
+ }
141
+ /**
142
+ * Build a Kuralle runtime for this DO (fresh per request to pick up latest
143
+ * config). Bridges CF messages + DO-backed orchestration/working-memory state.
144
+ * Shared by the chat path and the durable resume path.
145
+ */
146
+ buildRuntime() {
113
147
  const sessionId = this.getSessionId();
114
148
  const defaultAgentId = this.getDefaultAgentId();
115
- // Build session store that bridges CF messages with Kuralle state
116
149
  const sessionStore = new BridgeSessionStore({
117
150
  sqlExecutor: this.getSql(),
118
151
  cfMessages: this.messages,
119
152
  sessionId,
120
153
  defaultAgentId,
121
154
  });
122
- // Build runtime (fresh per request to pick up latest config)
123
155
  const extraConfig = this.getRuntimeConfig();
124
156
  const workingMemoryStore = this.getWorkingMemoryStore();
125
- this.runtime = createRuntime({
157
+ return createRuntime({
126
158
  ...extraConfig,
127
159
  agents: this.getAgents(),
128
160
  defaultAgentId,
@@ -131,39 +163,45 @@ export class KuralleAgent extends AIChatAgent {
131
163
  ? { defaultWorkingMemoryStore: workingMemoryStore }
132
164
  : {}),
133
165
  });
134
- const handle = this.runtime.run({
135
- input: lastUserMessage,
136
- sessionId,
137
- userId: options?.body?.userId,
138
- abortSignal: options?.abortSignal,
139
- });
140
- const streamConfig = {
141
- ...DEFAULT_STREAM_CONFIG,
142
- ...this.getStreamConfig(),
143
- };
144
- async function* parts() {
145
- for await (const part of handle.events) {
146
- yield part;
147
- }
166
+ }
167
+ /**
168
+ * Resume a suspended run by delivering a durable signal — the server-side
169
+ * counterpart to a human/out-of-band event (e.g. a paid checkout link being
170
+ * hit). Drives the resumed turn to completion, then persists **and broadcasts**
171
+ * the resumed assistant reply through CF's machinery, so a live client sees it
172
+ * and a reconnecting client replays it from history.
173
+ *
174
+ * Idempotent at the durable layer: delivering the same `signal.signalId` twice
175
+ * is deduplicated by the effect log, so a double-clicked link is safe.
176
+ *
177
+ * @returns the assistant text produced by the resumed turn (may be empty).
178
+ */
179
+ async resumeWithSignal(signal) {
180
+ const sessionId = this.getSessionId();
181
+ const runtime = this.buildRuntime();
182
+ const handle = runtime.run({ sessionId, signalDelivery: signal });
183
+ let text = '';
184
+ for await (const part of handle.events) {
185
+ if (part.type === 'text-delta')
186
+ text += part.delta;
148
187
  }
149
- return createSSEResponse(parts(), streamConfig);
188
+ await handle;
189
+ if (text.trim()) {
190
+ const assistantMessage = {
191
+ id: crypto.randomUUID(),
192
+ role: 'assistant',
193
+ parts: [{ type: 'text', text }],
194
+ };
195
+ await this.persistMessages([...this.messages, assistantMessage]);
196
+ }
197
+ return { text };
150
198
  }
151
199
  /**
152
- * Extract the text content of the last user message from CF's messages array.
200
+ * Extract the last user turn from CF's messages as runtime input (multimodal).
201
+ * See `lastUserInputFromMessages`.
153
202
  */
154
203
  getLastUserInput() {
155
- for (let i = this.messages.length - 1; i >= 0; i--) {
156
- const msg = this.messages[i];
157
- if (msg.role !== 'user')
158
- continue;
159
- const text = msg.parts
160
- ?.filter((part) => part.type === 'text')
161
- .map((part) => part.text)
162
- .join('');
163
- if (text?.trim())
164
- return text;
165
- }
166
- return null;
204
+ return lastUserInputFromMessages(this.messages);
167
205
  }
168
206
  /**
169
207
  * Delete orchestration rows older than `maxAgeMs`. Returns the number of
@@ -185,12 +223,87 @@ export class KuralleAgent extends AIChatAgent {
185
223
  const store = new OrchestrationStore(this.getSql());
186
224
  return store.cleanup(maxAgeMs);
187
225
  }
226
+ /**
227
+ * Durable scheduler backed by the agents SDK's DO-alarm scheduling
228
+ * (`this.schedule`). Jobs survive isolate restarts and fire in this exact
229
+ * DO via `runScheduledKuralleJob`. Satisfies the core `Scheduler` contract,
230
+ * so engagement drips/broadcasts and runtime wake turns can share it.
231
+ */
232
+ wakeScheduler() {
233
+ return {
234
+ enqueue: async (job, opts) => {
235
+ const delaySeconds = Math.max(0, Math.ceil((opts?.delayMs ?? 0) / 1000));
236
+ const schedule = await this.schedule(delaySeconds, 'runScheduledKuralleJob', job);
237
+ return schedule.id;
238
+ },
239
+ cancel: async (jobId) => {
240
+ await this.cancelSchedule(jobId);
241
+ },
242
+ };
243
+ }
244
+ /**
245
+ * Schedule a proactive wake turn for this DO's conversation (cart
246
+ * abandonment nudge, "check back in an hour", delivery follow-up).
247
+ * Returns the schedule id (cancellable via `wakeScheduler().cancel`).
248
+ */
249
+ async scheduleWake(delayMs, wake) {
250
+ return this.wakeScheduler().enqueue(wakeJob({ ...wake, sessionId: this.getSessionId() }), { delayMs });
251
+ }
252
+ /**
253
+ * DO-alarm callback for scheduled jobs. Wake jobs run an agent-initiated
254
+ * turn and persist + broadcast the assistant reply through CF's machinery
255
+ * (same path as `resumeWithSignal`); other job kinds go to
256
+ * `onScheduledJob` for subclasses to handle.
257
+ */
258
+ async runScheduledKuralleJob(job) {
259
+ if (!isWakeJob(job)) {
260
+ await this.onScheduledJob(job);
261
+ return;
262
+ }
263
+ const { reason, payload } = job.payload;
264
+ const runtime = this.buildRuntime();
265
+ const handle = runtime.run({
266
+ sessionId: this.getSessionId(),
267
+ wake: { reason, payload },
268
+ });
269
+ let text = '';
270
+ for await (const part of handle.events) {
271
+ if (part.type === 'text-delta')
272
+ text += part.delta;
273
+ }
274
+ await handle;
275
+ if (text.trim()) {
276
+ const assistantMessage = {
277
+ id: crypto.randomUUID(),
278
+ role: 'assistant',
279
+ parts: [{ type: 'text', text }],
280
+ };
281
+ await this.persistMessages([...this.messages, assistantMessage]);
282
+ }
283
+ }
284
+ /**
285
+ * Override to handle non-wake scheduled jobs (engagement drips, cleanup…).
286
+ * Default: no-op with a warning, so a mis-routed job is visible.
287
+ */
288
+ async onScheduledJob(job) {
289
+ console.warn(`[KuralleAgent] Unhandled scheduled job kind: ${job.kind}`);
290
+ }
188
291
  /**
189
292
  * HTTP endpoint handler.
190
293
  * Adds Kuralle-specific endpoints on top of CF's defaults.
191
294
  */
192
295
  async onRequest(request) {
193
296
  const url = new URL(request.url);
297
+ // Durable resume: deliver a signal to a suspended run (e.g. a paid checkout
298
+ // link). Body: { signalId: string, name: string, payload?: unknown }.
299
+ if (request.method === 'POST' && url.pathname.endsWith('/resume')) {
300
+ const body = (await request.json().catch(() => null));
301
+ if (!body || typeof body.signalId !== 'string' || typeof body.name !== 'string') {
302
+ return Response.json({ error: 'signalId and name are required' }, { status: 400 });
303
+ }
304
+ const { text } = await this.resumeWithSignal(body);
305
+ return Response.json({ ok: true, text });
306
+ }
194
307
  if (url.pathname.endsWith('/orchestration-state')) {
195
308
  const store = new OrchestrationStore(this.getSql());
196
309
  // OrchestrationStore is now keyed by sessionId (was a single `'default'`
@@ -0,0 +1,11 @@
1
+ import type { UserInputContent } from '@kuralle-agents/core';
2
+ import type { UIMessage } from 'ai';
3
+ /**
4
+ * Map the last user turn in a CF `UIMessage[]` to runtime `UserInputContent`.
5
+ *
6
+ * Text parts → `TextPart`; file parts (images / documents / audio uploaded via the
7
+ * CF chat client) → `FilePart` carrying the part's URL, so multimodal input reaches
8
+ * the model instead of being dropped. A text-only turn collapses to a plain string.
9
+ * Returns `null` when the last user turn carries no usable content.
10
+ */
11
+ export declare function lastUserInputFromMessages(messages: UIMessage[]): UserInputContent | null;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Map the last user turn in a CF `UIMessage[]` to runtime `UserInputContent`.
3
+ *
4
+ * Text parts → `TextPart`; file parts (images / documents / audio uploaded via the
5
+ * CF chat client) → `FilePart` carrying the part's URL, so multimodal input reaches
6
+ * the model instead of being dropped. A text-only turn collapses to a plain string.
7
+ * Returns `null` when the last user turn carries no usable content.
8
+ */
9
+ export function lastUserInputFromMessages(messages) {
10
+ for (let i = messages.length - 1; i >= 0; i--) {
11
+ const msg = messages[i];
12
+ if (!msg || msg.role !== 'user')
13
+ continue;
14
+ const content = [];
15
+ for (const part of msg.parts ?? []) {
16
+ if (part.type === 'text' && typeof part.text === 'string') {
17
+ content.push({ type: 'text', text: part.text });
18
+ }
19
+ else if (part.type === 'file' &&
20
+ typeof part.url === 'string' &&
21
+ typeof part.mediaType === 'string') {
22
+ content.push({
23
+ type: 'file',
24
+ data: part.url,
25
+ mediaType: part.mediaType,
26
+ filename: part.filename,
27
+ });
28
+ }
29
+ }
30
+ if (content.length === 0)
31
+ continue;
32
+ const hasFile = content.some((p) => p.type === 'file');
33
+ if (!hasFile) {
34
+ const text = content.map((p) => (p.type === 'text' ? p.text : '')).join('');
35
+ if (text.trim())
36
+ return text;
37
+ continue;
38
+ }
39
+ return content;
40
+ }
41
+ return null;
42
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export { OrchestrationStore } from './OrchestrationStore.js';
29
29
  export { SqlPersistentMemoryStore } from './SqlPersistentMemoryStore.js';
30
30
  export { createSqlExecutor } from './sqlExecutor.js';
31
31
  export { createSSEResponse } from './StreamAdapter.js';
32
+ export { lastUserInputFromMessages } from './cfMessageInput.js';
32
33
  export type { StreamAdapterConfig, OrchestrationState, SqlExecutor, } from './types.js';
33
34
  export { DEFAULT_STREAM_CONFIG } from './types.js';
34
35
  export type { HarnessConfig, HarnessHooks, HarnessStreamPart, Session, } from '@kuralle-agents/core';
package/dist/index.js CHANGED
@@ -29,4 +29,5 @@ export { OrchestrationStore } from './OrchestrationStore.js';
29
29
  export { SqlPersistentMemoryStore } from './SqlPersistentMemoryStore.js';
30
30
  export { createSqlExecutor } from './sqlExecutor.js';
31
31
  export { createSSEResponse } from './StreamAdapter.js';
32
+ export { lastUserInputFromMessages } from './cfMessageInput.js';
32
33
  export { DEFAULT_STREAM_CONFIG } from './types.js';
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SessionDurableRuns } from '@kuralle-agents/core';
1
2
  /**
2
3
  * SQL executor type matching Cloudflare's Durable Object sql binding.
3
4
  *
@@ -27,6 +28,13 @@ export interface OrchestrationState {
27
28
  timestamp: string;
28
29
  }>;
29
30
  state?: Record<string, unknown>;
31
+ /**
32
+ * Kuralle durable run journal (effect log + run state) for this session.
33
+ * `SessionRunStore` keeps the run on the Session object, so the bridge must
34
+ * persist + restore it here — otherwise durable tools and suspend/resume fail
35
+ * with "Run not found" on CF.
36
+ */
37
+ durableRuns?: SessionDurableRuns;
30
38
  }
31
39
  /**
32
40
  * Configuration for the stream adapter.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-agents/cf-agent",
3
- "version": "0.7.2",
3
+ "version": "0.8.5",
4
4
  "description": "Kuralle agent integration for Cloudflare Workers with AIChatAgent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,22 +19,22 @@
19
19
  "dist"
20
20
  ],
21
21
  "dependencies": {
22
- "@cloudflare/ai-chat": "^0.1.8",
22
+ "@cloudflare/ai-chat": "^0.8.4",
23
23
  "ai": "^6.0.90",
24
- "@kuralle-agents/core": "0.7.2",
25
- "@kuralle-agents/realtime-audio": "0.7.2"
24
+ "@kuralle-agents/core": "0.8.5",
25
+ "@kuralle-agents/realtime-audio": "0.8.5"
26
26
  },
27
27
  "peerDependencies": {
28
- "agents": "^0.11.5",
28
+ "agents": ">=0.14.0 <1.0.0",
29
29
  "zod": "^4.0.0",
30
- "@kuralle-agents/voice-protocol": "0.7.2"
30
+ "@kuralle-agents/voice-protocol": "0.8.5"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@cloudflare/vitest-pool-workers": "^0.12.7",
34
- "agents": "^0.11.5",
34
+ "agents": "^0.15.0",
35
35
  "typescript": "^5.7.0",
36
36
  "vitest": "^3.2.4",
37
- "@kuralle-agents/voice-protocol": "0.7.2"
37
+ "@kuralle-agents/voice-protocol": "0.8.5"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=20"