@kuralle-agents/cf-agent 0.7.2 → 0.8.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.
@@ -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 } 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
  /**
@@ -26,6 +26,7 @@ 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
@@ -191,6 +229,16 @@ export class KuralleAgent extends AIChatAgent {
191
229
  */
192
230
  async onRequest(request) {
193
231
  const url = new URL(request.url);
232
+ // Durable resume: deliver a signal to a suspended run (e.g. a paid checkout
233
+ // link). Body: { signalId: string, name: string, payload?: unknown }.
234
+ if (request.method === 'POST' && url.pathname.endsWith('/resume')) {
235
+ const body = (await request.json().catch(() => null));
236
+ if (!body || typeof body.signalId !== 'string' || typeof body.name !== 'string') {
237
+ return Response.json({ error: 'signalId and name are required' }, { status: 400 });
238
+ }
239
+ const { text } = await this.resumeWithSignal(body);
240
+ return Response.json({ ok: true, text });
241
+ }
194
242
  if (url.pathname.endsWith('/orchestration-state')) {
195
243
  const store = new OrchestrationStore(this.getSql());
196
244
  // 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.0",
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.0",
25
+ "@kuralle-agents/realtime-audio": "0.8.0"
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.0"
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.0"
38
38
  },
39
39
  "engines": {
40
40
  "node": ">=20"