@canonmsg/agent-sdk 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,7 +75,7 @@ The `message` event handler receives a context object with:
75
75
  | `conversation` | `SDKConversation` | Full conversation metadata |
76
76
  | `reply` | `(text: string, options?) => Promise<{ messageId: string }>` | Convenience alias for `replyFinal` |
77
77
  | `replyFinal` | `(text: string, options?) => Promise<{ messageId: string }>` | Send the durable final reply for a turn |
78
- | `replyProgress` | `(text: string, options?) => Promise<{ messageId: string }>` | Send a durable progress update during a turn |
78
+ | `replyProgress` | `(text: string, options?) => Promise<{ turnId: string; durable: boolean; messageId: string \| null }>` | Update the live turn progress; add `durable: true` to also persist it |
79
79
  | `agent` | `AgentContext` | Trusted Canon agent identity and access context |
80
80
  | `session` | `SessionInfo \| undefined` | Per-conversation queue/session state when sessions are enabled |
81
81
  | `turn` | `TurnController \| undefined` | Live turn-state helpers for thinking/streaming/tool/waiting-input |
@@ -178,3 +178,5 @@ While a handler runs, the SDK automatically publishes Canon turn state and clear
178
178
  - `setWaitingInput(text?)`
179
179
 
180
180
  `setWaitingInput()` keeps the turn open in `waiting_input` and optionally sends a control message to the conversation so Canon clients can render “reply to continue” correctly.
181
+
182
+ `replyProgress()` is ephemeral by default: it updates the live RTDB turn preview without adding a permanent Firestore message. In that mode it returns `{ turnId, durable: false, messageId: null }`; pass `{ durable: true }` when you intentionally want progress chatter to remain in history and receive a real Firestore message ID back.
@@ -1,4 +1,4 @@
1
- import { CanonClient, initRTDBAuth, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from '@canonmsg/core';
1
+ import { CanonClient, FINAL_MESSAGE_HANDOFF_MS, initRTDBAuth, writeSessionState, clearSessionState, writeTurnState, clearTurnState, } from '@canonmsg/core';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
@@ -10,8 +10,11 @@ const SDK_RUNTIME_CAPABILITIES = {
10
10
  supportsQueue: true,
11
11
  supportsInterleave: false,
12
12
  supportsRequiresAction: false,
13
- supportsNonFinalPermanentMessages: true,
13
+ supportsNonFinalPermanentMessages: false,
14
14
  };
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
15
18
  export class CanonAgent {
16
19
  options;
17
20
  apiClient;
@@ -251,7 +254,6 @@ export class CanonAgent {
251
254
  await this.apiClient.setTyping(conversationId, true, 'typing');
252
255
  }
253
256
  catch { }
254
- await writeTurn('completed');
255
257
  const result = await this.apiClient.sendMessage(conversationId, text, {
256
258
  ...(options ?? {}),
257
259
  metadata: {
@@ -261,27 +263,29 @@ export class CanonAgent {
261
263
  turnComplete: true,
262
264
  },
263
265
  });
266
+ await sleep(FINAL_MESSAGE_HANDOFF_MS);
264
267
  try {
265
268
  await this.apiClient.setTyping(conversationId, false);
266
269
  }
267
270
  catch { }
268
- try {
269
- await this.apiClient.clearStreaming(conversationId);
270
- }
271
- catch { }
272
271
  return result;
273
272
  };
274
273
  const replyProgress = async (text, options) => {
275
274
  await setLiveState('streaming', text, 'streaming');
276
- return this.apiClient.sendMessage(conversationId, text, {
277
- ...(options ?? {}),
275
+ if (!options?.durable) {
276
+ return { turnId, durable: false, messageId: null };
277
+ }
278
+ const { durable: _durable, ...sendOptions } = options;
279
+ const result = await this.apiClient.sendMessage(conversationId, text, {
280
+ ...sendOptions,
278
281
  metadata: {
279
- ...(options?.metadata ?? {}),
282
+ ...(sendOptions.metadata ?? {}),
280
283
  turnId,
281
284
  turnSemantics: 'progress',
282
285
  turnComplete: false,
283
286
  },
284
287
  });
288
+ return { turnId, durable: true, messageId: result.messageId };
285
289
  };
286
290
  // Enrich history messages with isOwner
287
291
  if (this.agentContext?.ownerId) {
package/dist/index.d.ts CHANGED
@@ -3,4 +3,4 @@ export { CanonApiError } from '@canonmsg/core';
3
3
  export { SessionManager } from './session-manager.js';
4
4
  export type { SessionConfig, Session } from './session-manager.js';
5
5
  export type { AgentContext, CanonMessage, CanonConversation, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
6
- export type { SDKMessage, SDKConversation, CanonAgentOptions, MessageHandler, MessageHandlerContext, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
6
+ export type { SDKMessage, SDKConversation, CanonAgentOptions, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
@@ -0,0 +1,10 @@
1
+ import { type CanonMessage, type ParticipationHistorySnapshot } from '@canonmsg/core';
2
+ export type { ParticipationHistorySnapshot } from '@canonmsg/core';
3
+ /**
4
+ * Builds message-specific participation history snapshots for backlog delivery.
5
+ *
6
+ * `messages` must be ordered newest-first, matching Canon's `getMessages()`
7
+ * API. Each snapshot is computed from older history only, never from the
8
+ * target message itself or newer messages that had not occurred yet.
9
+ */
10
+ export declare function buildParticipationHistorySnapshots(messages: CanonMessage[], agentId: string): Map<string, ParticipationHistorySnapshot>;
@@ -0,0 +1,11 @@
1
+ import { buildParticipationHistorySnapshots as buildSharedParticipationHistorySnapshots, } from '@canonmsg/core';
2
+ /**
3
+ * Builds message-specific participation history snapshots for backlog delivery.
4
+ *
5
+ * `messages` must be ordered newest-first, matching Canon's `getMessages()`
6
+ * API. Each snapshot is computed from older history only, never from the
7
+ * target message itself or newer messages that had not occurred yet.
8
+ */
9
+ export function buildParticipationHistorySnapshots(messages, agentId) {
10
+ return buildSharedParticipationHistorySnapshots(messages, agentId);
11
+ }
package/dist/polling.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildParticipationHistorySnapshots } from './policy-history.js';
1
2
  import { shouldDispatchInboundMessage } from './turn-filter.js';
2
3
  export class PollingManager {
3
4
  apiClient;
@@ -32,7 +33,9 @@ export class PollingManager {
32
33
  const activeConvos = this.findActiveConversations(conversations);
33
34
  await Promise.all(activeConvos.map(async (convo) => {
34
35
  try {
35
- const messages = await this.apiClient.getMessages(convo.id, 50);
36
+ const page = await this.apiClient.getMessagesPage(convo.id, 50);
37
+ const messages = page.messages;
38
+ const participationHistory = buildParticipationHistorySnapshots(messages, this.agentId);
36
39
  // Filter to only new messages (after lastSeen, not from self)
37
40
  const lastSeen = this.lastSeenTimestamps.get(convo.id) || 0;
38
41
  const newMessages = messages.filter((m) => {
@@ -41,7 +44,13 @@ export class PollingManager {
41
44
  });
42
45
  const dispatchable = await Promise.all(newMessages.map(async (message) => ({
43
46
  message,
44
- allow: await shouldDispatchInboundMessage(convo.id, this.agentId, message),
47
+ allow: await shouldDispatchInboundMessage(convo.id, this.agentId, message, {
48
+ conversationType: convo.type,
49
+ behavior: page.behavior,
50
+ recentHumanCount: participationHistory.get(message.id)?.recentHumanCount,
51
+ consecutiveAgentTurns: participationHistory.get(message.id)?.consecutiveAgentTurns,
52
+ currentAgentStreakStartedByHuman: participationHistory.get(message.id)?.currentAgentStreakStartedByHuman,
53
+ }),
45
54
  })));
46
55
  for (const msg of dispatchable.filter((entry) => entry.allow).map((entry) => entry.message)) {
47
56
  this.debouncer.add(convo.id, msg);
package/dist/realtime.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CanonClient, CanonStream } from '@canonmsg/core';
2
+ import { buildParticipationHistorySnapshots } from './policy-history.js';
2
3
  import { shouldDispatchInboundMessage } from './turn-filter.js';
3
4
  const DISCOVERY_INTERVAL_MS = 5_000;
4
5
  /**
@@ -108,10 +109,19 @@ export class RealtimeManager {
108
109
  // Fetch and deliver pending messages from new conversations
109
110
  for (const convoId of newConvoIds) {
110
111
  try {
111
- const messages = await this.apiClient.getMessages(convoId, 50);
112
+ const conversation = convos.find((item) => item.id === convoId);
113
+ const page = await this.apiClient.getMessagesPage(convoId, 50);
114
+ const messages = page.messages;
115
+ const participationHistory = buildParticipationHistorySnapshots(messages, this.agentId);
112
116
  const dispatchable = await Promise.all(messages.map(async (message) => ({
113
117
  message,
114
- allow: await shouldDispatchInboundMessage(convoId, this.agentId, message),
118
+ allow: await shouldDispatchInboundMessage(convoId, this.agentId, message, {
119
+ conversationType: conversation?.type ?? 'unknown',
120
+ behavior: page.behavior,
121
+ recentHumanCount: participationHistory.get(message.id)?.recentHumanCount,
122
+ consecutiveAgentTurns: participationHistory.get(message.id)?.consecutiveAgentTurns,
123
+ currentAgentStreakStartedByHuman: participationHistory.get(message.id)?.currentAgentStreakStartedByHuman,
124
+ }),
115
125
  })));
116
126
  const newMessages = dispatchable
117
127
  .filter((entry) => entry.allow)
@@ -1,2 +1,8 @@
1
- import { type CanonMessage } from '@canonmsg/core';
2
- export declare function shouldDispatchInboundMessage(conversationId: string, agentId: string, message: CanonMessage): Promise<boolean>;
1
+ import { type ResolvedAgentBehaviorPolicy, type CanonMessage } from '@canonmsg/core';
2
+ export declare function shouldDispatchInboundMessage(conversationId: string, agentId: string, message: CanonMessage, options?: {
3
+ conversationType?: 'direct' | 'group' | 'unknown';
4
+ behavior?: ResolvedAgentBehaviorPolicy | null;
5
+ recentHumanCount?: number;
6
+ consecutiveAgentTurns?: number;
7
+ currentAgentStreakStartedByHuman?: boolean;
8
+ }): Promise<boolean>;
@@ -1,4 +1,4 @@
1
- import { normalizeTurnState, rtdbRead, shouldTriggerAgentTurn, } from '@canonmsg/core';
1
+ import { evaluateParticipationPolicy, normalizeTurnState, rtdbRead, shouldTriggerAgentTurn, } from '@canonmsg/core';
2
2
  function normalizeRuntimeTurnState(value) {
3
3
  const turnState = normalizeTurnState(value);
4
4
  if (turnState) {
@@ -15,7 +15,7 @@ function normalizeRuntimeTurnState(value) {
15
15
  }
16
16
  return null;
17
17
  }
18
- export async function shouldDispatchInboundMessage(conversationId, agentId, message) {
18
+ export async function shouldDispatchInboundMessage(conversationId, agentId, message, options) {
19
19
  if (message.senderId === agentId)
20
20
  return false;
21
21
  let senderTurnState = null;
@@ -30,9 +30,22 @@ export async function shouldDispatchInboundMessage(conversationId, agentId, mess
30
30
  catch {
31
31
  senderTurnState = null;
32
32
  }
33
- return shouldTriggerAgentTurn({
33
+ const triggerDecision = shouldTriggerAgentTurn({
34
34
  senderType: message.senderType,
35
35
  metadata: message.metadata,
36
36
  senderTurnState,
37
+ });
38
+ if (!triggerDecision.allow)
39
+ return false;
40
+ if (!options?.behavior)
41
+ return true;
42
+ return evaluateParticipationPolicy(options.behavior, {
43
+ conversationType: options.conversationType ?? 'unknown',
44
+ senderType: message.senderType,
45
+ isOwner: message.isOwner,
46
+ mentionedAgent: Array.isArray(message.mentions) && message.mentions.includes(agentId),
47
+ recentHumanCount: options.recentHumanCount,
48
+ consecutiveAgentTurns: options.consecutiveAgentTurns,
49
+ currentAgentStreakStartedByHuman: options.currentAgentStreakStartedByHuman,
37
50
  }).allow;
38
51
  }
package/dist/types.d.ts CHANGED
@@ -2,6 +2,22 @@ export type { AgentClientType, CanonMessage, CanonConversation, AgentContext, Se
2
2
  import type { CanonMessage, CanonConversation, SendMessageOptions } from '@canonmsg/core';
3
3
  export type SDKMessage = CanonMessage;
4
4
  export type SDKConversation = CanonConversation;
5
+ export interface ProgressMessageOptions extends SendMessageOptions {
6
+ /**
7
+ * Persist the progress update to Firestore.
8
+ * By default, progress stays ephemeral and only updates the live RTDB turn state.
9
+ */
10
+ durable?: boolean;
11
+ }
12
+ export type ProgressMessageResult = {
13
+ turnId: string;
14
+ durable: false;
15
+ messageId: null;
16
+ } | {
17
+ turnId: string;
18
+ durable: true;
19
+ messageId: string;
20
+ };
5
21
  export interface SessionInfo {
6
22
  /** Session ID (= conversationId) */
7
23
  id: string;
@@ -30,9 +46,7 @@ export interface MessageHandlerContext {
30
46
  replyFinal: (text: string, options?: SendMessageOptions) => Promise<{
31
47
  messageId: string;
32
48
  }>;
33
- replyProgress: (text: string, options?: SendMessageOptions) => Promise<{
34
- messageId: string;
35
- }>;
49
+ replyProgress: (text: string, options?: ProgressMessageOptions) => Promise<ProgressMessageResult>;
36
50
  /** Soft-delete a message (agent must be the sender) */
37
51
  deleteMessage: (messageId: string) => Promise<void>;
38
52
  /** Mark conversation as read */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -23,7 +23,7 @@
23
23
  "node": ">=18.0.0"
24
24
  },
25
25
  "dependencies": {
26
- "@canonmsg/core": "^0.3.0"
26
+ "@canonmsg/core": "^0.4.0"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"