@canonmsg/agent-sdk 1.2.1 → 1.3.1

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,5 +1,5 @@
1
- import { type AddMemberResult, type CanonContact, type ContactCardPayload, type CreateContactRequestResult } from '@canonmsg/core';
2
- import type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler, RuntimeSignalHandler } from './types.js';
1
+ import { type AddMemberResult, type CanonContact, type CanonRuntimePrimitiveId, type ContactCardPayload, type CreateContactRequestResult } from '@canonmsg/core';
2
+ import type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, CreateConversationOptions, MessageHandler, ReachOutOptions, ReachOutResult, ContactRequestHandler, RuntimeSignalHandler, RuntimePrimitiveHandler } from './types.js';
3
3
  /**
4
4
  * Contact-graph operations exposed under `agent.contacts`. Wraps the REST
5
5
  * endpoints in CanonClient — the same surface a human user would hit through
@@ -33,6 +33,8 @@ export declare class CanonAgent {
33
33
  private interruptHandler;
34
34
  private stopAndDropHandler;
35
35
  private newSessionHandler;
36
+ private readonly primitiveHandlers;
37
+ private primitiveFallbackHandler;
36
38
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
37
39
  readonly contacts: AgentContactsAPI;
38
40
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -45,7 +47,10 @@ export declare class CanonAgent {
45
47
  private runtimeHeartbeatTimer;
46
48
  private runtimeControlPollTimer;
47
49
  private readonly lastSeenSignal;
50
+ private readonly primitiveRequestDedupe;
48
51
  private readonly activeAbortControllers;
52
+ private readonly conversationMemberIds;
53
+ private readonly pendingMembershipChanges;
49
54
  constructor(options: CanonAgentOptions);
50
55
  on(event: 'message', handler: MessageHandler): void;
51
56
  on(event: 'contactRequest', handler: ContactRequestHandler): void;
@@ -55,6 +60,7 @@ export declare class CanonAgent {
55
60
  on(event: 'interrupt', handler: RuntimeSignalHandler): void;
56
61
  on(event: 'stopAndDrop', handler: RuntimeSignalHandler): void;
57
62
  on(event: 'newSession', handler: RuntimeSignalHandler): void;
63
+ onPrimitive(primitive: CanonRuntimePrimitiveId | '*', handler: RuntimePrimitiveHandler): void;
58
64
  /**
59
65
  * Resolve admission live for a target user (typically read off a shared
60
66
  * contact card) and route into either an immediate message or a contact
@@ -98,6 +104,8 @@ export declare class CanonAgent {
98
104
  private hasStopAndDropSupport;
99
105
  private hasNewSessionSupport;
100
106
  private hasRuntimeSignalSupport;
107
+ private hasRuntimePrimitiveSupport;
108
+ private hasRuntimeControlSupport;
101
109
  private buildRuntimeDescriptor;
102
110
  private buildRuntimeCapabilities;
103
111
  private publishAgentRuntime;
@@ -105,10 +113,16 @@ export declare class CanonAgent {
105
113
  private stopRuntimeHeartbeat;
106
114
  private clearAgentRuntime;
107
115
  private rememberConversationId;
116
+ private rememberConversationMembers;
117
+ private handleConversationUpdated;
118
+ private buildGroupContext;
108
119
  private baselineRuntimeControlSignals;
109
120
  private startRuntimeControlPolling;
110
121
  private stopRuntimeControlPolling;
111
- private pollRuntimeControlSignals;
122
+ private pollRuntimeControls;
123
+ private handleRuntimePrimitiveRequests;
124
+ private clearRuntimePrimitiveRequest;
125
+ private prunePrimitiveRequestDedupe;
112
126
  private handleRuntimeSignal;
113
127
  private abortActiveTurns;
114
128
  private resolveBatchDeliveryIntent;
@@ -1,10 +1,12 @@
1
- import { CanonClient, createRuntimeStatePublisher, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, } from '@canonmsg/core';
1
+ import { CanonClient, buildCanonGroupContext, createRuntimeStatePublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveMessageActiveSelfContextId, selectActiveSelfContexts, } from '@canonmsg/core';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { AuthManager } from './auth.js';
4
4
  import { Debouncer } from './debouncer.js';
5
5
  import { materializeMessageMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
6
6
  import { SessionManager } from './session-manager.js';
7
7
  const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
8
+ const RUNTIME_PRIMITIVE_DEDUPE_TTL_MS = 5 * 60 * 1000;
9
+ const RUNTIME_PRIMITIVE_DEDUPE_MAX = 1_000;
8
10
  const SDK_RUNTIME_CAPABILITIES = {
9
11
  supportsInterrupt: false,
10
12
  supportsQueue: true,
@@ -18,9 +20,133 @@ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
18
20
  supportsInterrupt: false,
19
21
  streamingTextMode: 'snapshot',
20
22
  };
23
+ const STANDARD_PRIMITIVE_COMMANDS = {
24
+ 'runtime.status': {
25
+ id: 'runtime-status',
26
+ label: 'Runtime status',
27
+ description: 'Ask the runtime for its current status.',
28
+ primitive: 'runtime.status',
29
+ aliases: ['status'],
30
+ category: 'runtime',
31
+ placements: ['composer_slash', 'command_palette'],
32
+ availability: ['always'],
33
+ dispatch: { kind: 'primitive', primitive: 'runtime.status' },
34
+ },
35
+ 'runtime.reasoning.set': {
36
+ id: 'thinking-level',
37
+ label: 'Thinking level',
38
+ description: 'Set the runtime reasoning or effort level.',
39
+ primitive: 'runtime.reasoning.set',
40
+ aliases: ['think', 'effort'],
41
+ category: 'runtime',
42
+ placements: ['composer_slash', 'command_palette'],
43
+ availability: ['always'],
44
+ args: [{
45
+ id: 'level',
46
+ label: 'Level',
47
+ kind: 'enum',
48
+ required: true,
49
+ choices: [
50
+ { value: 'low', label: 'Low' },
51
+ { value: 'medium', label: 'Medium' },
52
+ { value: 'high', label: 'High' },
53
+ ],
54
+ }],
55
+ dispatch: { kind: 'primitive', primitive: 'runtime.reasoning.set' },
56
+ },
57
+ 'runtime.verbosity.set': {
58
+ id: 'verbosity',
59
+ label: 'Verbosity',
60
+ description: 'Set runtime verbosity.',
61
+ primitive: 'runtime.verbosity.set',
62
+ aliases: ['verbose'],
63
+ category: 'runtime',
64
+ placements: ['composer_slash', 'command_palette'],
65
+ availability: ['always'],
66
+ args: [{
67
+ id: 'level',
68
+ label: 'Level',
69
+ kind: 'enum',
70
+ required: true,
71
+ choices: [
72
+ { value: 'off', label: 'Off' },
73
+ { value: 'on', label: 'On' },
74
+ { value: 'full', label: 'Full' },
75
+ ],
76
+ }],
77
+ dispatch: { kind: 'primitive', primitive: 'runtime.verbosity.set' },
78
+ },
79
+ 'runtime.usage': {
80
+ id: 'usage',
81
+ label: 'Usage',
82
+ description: 'Show or change runtime usage reporting.',
83
+ primitive: 'runtime.usage',
84
+ aliases: ['usage'],
85
+ category: 'runtime',
86
+ placements: ['composer_slash', 'command_palette'],
87
+ availability: ['always'],
88
+ dispatch: { kind: 'primitive', primitive: 'runtime.usage' },
89
+ },
90
+ 'context.compact': {
91
+ id: 'compact-context',
92
+ label: 'Compact context',
93
+ description: 'Ask the runtime to compact its conversation context.',
94
+ primitive: 'context.compact',
95
+ aliases: ['compact'],
96
+ category: 'session',
97
+ placements: ['composer_slash', 'command_palette'],
98
+ availability: ['always'],
99
+ dispatch: { kind: 'primitive', primitive: 'context.compact' },
100
+ },
101
+ 'session.new': {
102
+ id: 'new-session-primitive',
103
+ label: 'New session',
104
+ description: 'Ask the runtime to start a fresh session.',
105
+ primitive: 'session.new',
106
+ aliases: ['new'],
107
+ category: 'session',
108
+ placements: ['composer_slash', 'command_palette', 'session_strip'],
109
+ availability: ['always'],
110
+ dispatch: { kind: 'primitive', primitive: 'session.new' },
111
+ },
112
+ 'session.reset': {
113
+ id: 'reset-session',
114
+ label: 'Reset session',
115
+ description: 'Ask the runtime to reset the current session.',
116
+ primitive: 'session.reset',
117
+ aliases: ['reset'],
118
+ category: 'session',
119
+ placements: ['composer_slash', 'command_palette'],
120
+ availability: ['always'],
121
+ dispatch: { kind: 'primitive', primitive: 'session.reset' },
122
+ },
123
+ };
21
124
  function sleep(ms) {
22
125
  return new Promise((resolve) => setTimeout(resolve, ms));
23
126
  }
127
+ function isRuntimePrimitiveId(value) {
128
+ return value === 'runtime.status'
129
+ || value === 'runtime.reasoning.set'
130
+ || value === 'runtime.verbosity.set'
131
+ || value === 'runtime.usage'
132
+ || value === 'context.compact'
133
+ || value === 'session.new'
134
+ || value === 'session.reset';
135
+ }
136
+ function normalizePrimitiveArgs(value) {
137
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
138
+ return {};
139
+ }
140
+ const args = {};
141
+ for (const [key, rawValue] of Object.entries(value)) {
142
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
143
+ continue;
144
+ if (typeof rawValue === 'boolean' || typeof rawValue === 'string') {
145
+ args[key] = rawValue;
146
+ }
147
+ }
148
+ return args;
149
+ }
24
150
  export class CanonAgent {
25
151
  options;
26
152
  apiClient;
@@ -36,6 +162,8 @@ export class CanonAgent {
36
162
  interruptHandler = null;
37
163
  stopAndDropHandler = null;
38
164
  newSessionHandler = null;
165
+ primitiveHandlers = new Map();
166
+ primitiveFallbackHandler = null;
39
167
  /** Contact-graph operations (`agent.contacts.*`). Initialized in the constructor. */
40
168
  contacts;
41
169
  /** Block/unblock operations (`agent.users.*`). Initialized in the constructor. */
@@ -48,7 +176,10 @@ export class CanonAgent {
48
176
  runtimeHeartbeatTimer = null;
49
177
  runtimeControlPollTimer = null;
50
178
  lastSeenSignal = new Map();
179
+ primitiveRequestDedupe = new Map();
51
180
  activeAbortControllers = new Map();
181
+ conversationMemberIds = new Map();
182
+ pendingMembershipChanges = new Map();
52
183
  constructor(options) {
53
184
  this.options = {
54
185
  baseUrl: 'https://api-6m6mlelskq-uc.a.run.app',
@@ -82,6 +213,14 @@ export class CanonAgent {
82
213
  this.interruptHandler = options.runtimeControls?.onInterrupt ?? null;
83
214
  this.stopAndDropHandler = options.runtimeControls?.onStopAndDrop ?? null;
84
215
  this.newSessionHandler = options.runtimeControls?.onNewSession ?? null;
216
+ for (const [primitive, handler] of Object.entries(options.runtimePrimitives ?? {})) {
217
+ if (primitive === '*') {
218
+ this.primitiveFallbackHandler = handler;
219
+ }
220
+ else if (isRuntimePrimitiveId(primitive)) {
221
+ this.primitiveHandlers.set(primitive, handler);
222
+ }
223
+ }
85
224
  }
86
225
  on(event, handler) {
87
226
  if (event === 'message') {
@@ -132,6 +271,18 @@ export class CanonAgent {
132
271
  }
133
272
  this.contactRemovedHandler = handler;
134
273
  }
274
+ onPrimitive(primitive, handler) {
275
+ if (primitive === '*') {
276
+ this.primitiveFallbackHandler = handler;
277
+ }
278
+ else {
279
+ this.primitiveHandlers.set(primitive, handler);
280
+ }
281
+ if (this.running) {
282
+ this.startRuntimeControlPolling();
283
+ }
284
+ void this.publishAgentRuntime().catch(() => { });
285
+ }
135
286
  /**
136
287
  * Resolve admission live for a target user (typically read off a shared
137
288
  * contact card) and route into either an immediate message or a contact
@@ -178,6 +329,7 @@ export class CanonAgent {
178
329
  status: 'messaged',
179
330
  conversationId: result.conversationId,
180
331
  messageId: result.messageId,
332
+ selfContextId: result.selfContextId,
181
333
  }
182
334
  : result;
183
335
  }
@@ -207,6 +359,7 @@ export class CanonAgent {
207
359
  try {
208
360
  conversations = await this.apiClient.getConversations();
209
361
  this.cachedConversationIds = conversations.map((c) => c.id);
362
+ this.rememberConversationMembers(conversations);
210
363
  }
211
364
  catch {
212
365
  // Non-fatal — delivery mode will fall back to default
@@ -265,6 +418,9 @@ export class CanonAgent {
265
418
  void this.handleContactGraphEvent(this.contactRemovedHandler, payload);
266
419
  },
267
420
  });
421
+ rtm.setConversationUpdatedHandler((payload) => {
422
+ this.handleConversationUpdated(payload);
423
+ });
268
424
  rtm.setConnectionHandlers({
269
425
  onConnected: () => this.startRuntimeHeartbeat(),
270
426
  onDisconnected: () => this.stopRuntimeHeartbeat(),
@@ -364,6 +520,12 @@ export class CanonAgent {
364
520
  hasRuntimeSignalSupport() {
365
521
  return this.hasInterruptSupport() || this.hasStopAndDropSupport() || this.hasNewSessionSupport();
366
522
  }
523
+ hasRuntimePrimitiveSupport() {
524
+ return this.primitiveHandlers.size > 0 || Boolean(this.primitiveFallbackHandler);
525
+ }
526
+ hasRuntimeControlSupport() {
527
+ return this.hasRuntimeSignalSupport() || this.hasRuntimePrimitiveSupport();
528
+ }
367
529
  buildRuntimeDescriptor() {
368
530
  const source = this.options.runtimeDescriptor ?? DEFAULT_SDK_RUNTIME_DESCRIPTOR;
369
531
  const hasInterrupt = this.hasInterruptSupport();
@@ -392,9 +554,23 @@ export class CanonAgent {
392
554
  if (hasNewSession && !hasNewSessionAction) {
393
555
  actions.push(RUNTIME_NEW_SESSION_ACTION);
394
556
  }
557
+ const commands = [...(source.commands ?? [])].filter((command) => {
558
+ if (command.dispatch.kind !== 'primitive')
559
+ return true;
560
+ return this.primitiveHandlers.has(command.dispatch.primitive)
561
+ || Boolean(this.primitiveFallbackHandler);
562
+ });
563
+ const hasCommandForPrimitive = (primitive) => commands.some((command) => (command.primitive === primitive
564
+ || (command.dispatch.kind === 'primitive' && command.dispatch.primitive === primitive)));
565
+ for (const primitive of this.primitiveHandlers.keys()) {
566
+ if (!hasCommandForPrimitive(primitive)) {
567
+ commands.push(STANDARD_PRIMITIVE_COMMANDS[primitive]);
568
+ }
569
+ }
395
570
  return {
396
571
  ...source,
397
572
  supportsInterrupt: hasInterrupt,
573
+ commands,
398
574
  actions,
399
575
  };
400
576
  }
@@ -437,6 +613,41 @@ export class CanonAgent {
437
613
  return;
438
614
  this.cachedConversationIds.push(conversationId);
439
615
  }
616
+ rememberConversationMembers(conversations) {
617
+ for (const conversation of conversations) {
618
+ this.conversationMemberIds.set(conversation.id, [...(conversation.memberIds ?? [])]);
619
+ }
620
+ }
621
+ handleConversationUpdated(payload) {
622
+ const rawMemberIds = payload.changes.memberIds;
623
+ if (!Array.isArray(rawMemberIds))
624
+ return;
625
+ const memberIds = rawMemberIds.filter((id) => typeof id === 'string');
626
+ const hadPreviousMemberIds = this.conversationMemberIds.has(payload.conversationId);
627
+ const previousMemberIds = this.conversationMemberIds.get(payload.conversationId) ?? [];
628
+ const membershipChange = payload.membershipChange
629
+ ?? (hadPreviousMemberIds ? diffCanonMemberIds(previousMemberIds, memberIds) : null);
630
+ this.conversationMemberIds.set(payload.conversationId, memberIds);
631
+ if (membershipChange) {
632
+ this.pendingMembershipChanges.set(payload.conversationId, membershipChange);
633
+ }
634
+ if (this.agentId && !memberIds.includes(this.agentId)) {
635
+ this.cachedConversationIds = this.cachedConversationIds.filter((id) => id !== payload.conversationId);
636
+ }
637
+ else {
638
+ this.rememberConversationId(payload.conversationId);
639
+ }
640
+ }
641
+ buildGroupContext(input) {
642
+ return buildCanonGroupContext({
643
+ conversation: input.conversation,
644
+ messages: [...input.history, ...input.messages],
645
+ agentId: input.agent.agentId,
646
+ ownerId: input.agent.ownerId,
647
+ ownerName: input.agent.ownerName,
648
+ membershipChange: input.membershipChange,
649
+ });
650
+ }
440
651
  async baselineRuntimeControlSignals(conversationIds) {
441
652
  if (!this.agentId || !this.hasRuntimeSignalSupport())
442
653
  return;
@@ -451,10 +662,10 @@ export class CanonAgent {
451
662
  }));
452
663
  }
453
664
  startRuntimeControlPolling() {
454
- if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeSignalSupport())
665
+ if (!this.agentId || this.runtimeControlPollTimer || !this.hasRuntimeControlSupport())
455
666
  return;
456
667
  this.runtimeControlPollTimer = setInterval(() => {
457
- void this.pollRuntimeControlSignals();
668
+ void this.pollRuntimeControls();
458
669
  }, 2_000);
459
670
  this.runtimeControlPollTimer.unref?.();
460
671
  }
@@ -464,16 +675,97 @@ export class CanonAgent {
464
675
  clearInterval(this.runtimeControlPollTimer);
465
676
  this.runtimeControlPollTimer = null;
466
677
  }
467
- async pollRuntimeControlSignals() {
468
- if (!this.agentId || !this.hasRuntimeSignalSupport())
678
+ async pollRuntimeControls() {
679
+ if (!this.agentId || !this.hasRuntimeControlSupport())
469
680
  return;
470
681
  await Promise.all(this.cachedConversationIds.map(async (conversationId) => {
471
- const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
472
- if (!raw || typeof raw !== 'object')
473
- return;
474
- await this.handleRuntimeSignal(conversationId, raw);
682
+ if (this.hasRuntimeSignalSupport()) {
683
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/signal`)).catch(() => null);
684
+ if (raw && typeof raw === 'object') {
685
+ await this.handleRuntimeSignal(conversationId, raw);
686
+ }
687
+ }
688
+ if (this.hasRuntimePrimitiveSupport()) {
689
+ const raw = await Promise.resolve(rtdbRead(`/control/${conversationId}/${this.agentId}/primitive`)).catch(() => null);
690
+ if (raw && typeof raw === 'object') {
691
+ await this.handleRuntimePrimitiveRequests(conversationId, raw);
692
+ }
693
+ }
475
694
  }));
476
695
  }
696
+ async handleRuntimePrimitiveRequests(conversationId, raw) {
697
+ if (!this.agentId)
698
+ return;
699
+ this.prunePrimitiveRequestDedupe();
700
+ const requests = Object.entries(raw)
701
+ .map(([requestId, value]) => ({ requestId, value }))
702
+ .filter((entry) => (Boolean(entry.requestId)
703
+ && Boolean(entry.value)
704
+ && typeof entry.value === 'object'
705
+ && !Array.isArray(entry.value)))
706
+ .sort((a, b) => Number(a.value.updatedAt ?? 0) - Number(b.value.updatedAt ?? 0));
707
+ for (const { requestId, value } of requests) {
708
+ const requestKey = `${conversationId}:${requestId}`;
709
+ if (this.primitiveRequestDedupe.has(requestKey))
710
+ continue;
711
+ this.primitiveRequestDedupe.set(requestKey, Date.now());
712
+ let cleared = false;
713
+ try {
714
+ const primitive = value.id;
715
+ if (!isRuntimePrimitiveId(primitive)) {
716
+ cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
717
+ continue;
718
+ }
719
+ const handler = this.primitiveHandlers.get(primitive) ?? this.primitiveFallbackHandler;
720
+ if (!handler) {
721
+ cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
722
+ continue;
723
+ }
724
+ const args = normalizePrimitiveArgs(value.args);
725
+ await Promise.resolve(handler({
726
+ conversationId,
727
+ primitive,
728
+ args,
729
+ requestId,
730
+ updatedAt: typeof value.updatedAt === 'number' ? value.updatedAt : undefined,
731
+ rawText: typeof value.rawText === 'string' ? value.rawText : undefined,
732
+ alias: typeof value.alias === 'string' ? value.alias : undefined,
733
+ })).catch((error) => {
734
+ console.error(`[canon-sdk] Runtime primitive ${primitive} handler failed for ${conversationId}:`, error);
735
+ });
736
+ cleared = await this.clearRuntimePrimitiveRequest(conversationId, requestId);
737
+ }
738
+ finally {
739
+ if (cleared) {
740
+ this.primitiveRequestDedupe.delete(requestKey);
741
+ }
742
+ }
743
+ }
744
+ }
745
+ async clearRuntimePrimitiveRequest(conversationId, requestId) {
746
+ if (!this.agentId)
747
+ return false;
748
+ try {
749
+ await Promise.resolve(rtdbWrite(`/control/${conversationId}/${this.agentId}/primitive/${requestId}`, null));
750
+ return true;
751
+ }
752
+ catch {
753
+ return false;
754
+ }
755
+ }
756
+ prunePrimitiveRequestDedupe(now = Date.now()) {
757
+ for (const [key, timestamp] of this.primitiveRequestDedupe) {
758
+ if (now - timestamp >= RUNTIME_PRIMITIVE_DEDUPE_TTL_MS) {
759
+ this.primitiveRequestDedupe.delete(key);
760
+ }
761
+ }
762
+ while (this.primitiveRequestDedupe.size > RUNTIME_PRIMITIVE_DEDUPE_MAX) {
763
+ const oldestKey = this.primitiveRequestDedupe.keys().next().value;
764
+ if (!oldestKey)
765
+ break;
766
+ this.primitiveRequestDedupe.delete(oldestKey);
767
+ }
768
+ }
477
769
  async handleRuntimeSignal(conversationId, raw) {
478
770
  if (!this.agentId)
479
771
  return;
@@ -655,6 +947,7 @@ export class CanonAgent {
655
947
  }
656
948
  // Get conversation info
657
949
  const conversations = await this.apiClient.getConversations();
950
+ this.rememberConversationMembers(conversations);
658
951
  const conversation = conversations.find((c) => c.id === conversationId);
659
952
  if (!conversation)
660
953
  return;
@@ -664,13 +957,11 @@ export class CanonAgent {
664
957
  await this.apiClient.setTyping(conversationId, true, 'typing');
665
958
  }
666
959
  catch { }
960
+ const sendOptions = withActiveSelfContext(options);
667
961
  const result = await this.apiClient.sendMessage(conversationId, text, {
668
- ...(options ?? {}),
669
- ...(options?.selfContextId === undefined && activeSelfContextId
670
- ? { selfContextId: activeSelfContextId }
671
- : {}),
962
+ ...sendOptions,
672
963
  metadata: {
673
- ...(options?.metadata ?? {}),
964
+ ...(sendOptions.metadata ?? {}),
674
965
  turnId,
675
966
  turnSemantics: 'turn_complete',
676
967
  turnComplete: true,
@@ -689,10 +980,11 @@ export class CanonAgent {
689
980
  return { turnId, durable: false, messageId: null };
690
981
  }
691
982
  const { durable: _durable, ...sendOptions } = options;
983
+ const sendOptionsWithContext = withActiveSelfContext(sendOptions);
692
984
  const result = await this.apiClient.sendMessage(conversationId, text, {
693
- ...sendOptions,
985
+ ...sendOptionsWithContext,
694
986
  metadata: {
695
- ...(sendOptions.metadata ?? {}),
987
+ ...(sendOptionsWithContext.metadata ?? {}),
696
988
  turnId,
697
989
  turnSemantics: 'progress',
698
990
  turnComplete: false,
@@ -707,8 +999,19 @@ export class CanonAgent {
707
999
  m.isOwner = m.senderId === ownerId;
708
1000
  }
709
1001
  }
710
- const selfContexts = page.selfContexts ?? [];
711
- const activeSelfContextId = selfContexts[0]?.id;
1002
+ const latestMessage = hydratedMessages[hydratedMessages.length - 1] ?? null;
1003
+ const resolvedActiveSelfContextId = resolveMessageActiveSelfContextId({
1004
+ messageId: latestMessage?.id,
1005
+ activeSelfContextIdByMessageId: page.activeSelfContextIdByMessageId,
1006
+ });
1007
+ const selfContexts = selectActiveSelfContexts(page.selfContexts, resolvedActiveSelfContextId);
1008
+ const activeSelfContextId = selfContexts.length > 0 ? resolvedActiveSelfContextId : null;
1009
+ const withActiveSelfContext = (options) => {
1010
+ const base = { ...(options ?? {}) };
1011
+ if (base.selfContextId !== undefined)
1012
+ return base;
1013
+ return activeSelfContextId ? { ...base, selfContextId: activeSelfContextId } : base;
1014
+ };
712
1015
  // Build agent context (fallback to minimal if not yet received)
713
1016
  const agent = this.agentContext ?? {
714
1017
  agentId: this.agentId,
@@ -718,6 +1021,15 @@ export class CanonAgent {
718
1021
  inboundPolicy: 'approval-required',
719
1022
  groupJoinPolicy: 'approval-required',
720
1023
  };
1024
+ const membershipChange = this.pendingMembershipChanges.get(conversationId) ?? null;
1025
+ this.pendingMembershipChanges.delete(conversationId);
1026
+ const groupContext = this.buildGroupContext({
1027
+ conversation,
1028
+ history,
1029
+ messages: hydratedMessages,
1030
+ agent,
1031
+ membershipChange,
1032
+ });
721
1033
  // Build context methods bound to this conversation
722
1034
  const deleteMessage = (messageId) => this.apiClient.deleteMessage(conversationId, messageId);
723
1035
  const markAsRead = () => this.apiClient.markAsRead(conversationId);
@@ -740,6 +1052,10 @@ export class CanonAgent {
740
1052
  },
741
1053
  },
742
1054
  });
1055
+ const reachOut = (card, options) => this.reachOut(card, {
1056
+ ...(options ?? {}),
1057
+ sourceConversationId: conversationId,
1058
+ });
743
1059
  const uploadFile = (filePath, options) => uploadMediaFile(this.apiClient, conversationId, filePath, options);
744
1060
  const replyWithFile = async (filePath, text = '', options) => {
745
1061
  try {
@@ -753,9 +1069,9 @@ export class CanonAgent {
753
1069
  ? { replyToPosition: options.replyToPosition }
754
1070
  : {}),
755
1071
  ...(options?.mentions ? { mentions: options.mentions } : {}),
756
- ...(options?.selfContextId === undefined && activeSelfContextId
757
- ? { selfContextId: activeSelfContextId }
758
- : {}),
1072
+ ...withActiveSelfContext(options?.selfContextId !== undefined
1073
+ ? { selfContextId: options.selfContextId }
1074
+ : undefined),
759
1075
  metadata: {
760
1076
  ...(options?.metadata ?? {}),
761
1077
  turnId,
@@ -782,6 +1098,7 @@ export class CanonAgent {
782
1098
  history,
783
1099
  conversationId,
784
1100
  conversation,
1101
+ ...(groupContext ? { groupContext } : {}),
785
1102
  replyFinal,
786
1103
  replyProgress,
787
1104
  deleteMessage,
@@ -791,7 +1108,9 @@ export class CanonAgent {
791
1108
  addMember,
792
1109
  removeMember,
793
1110
  sendContextualMessage,
1111
+ reachOut,
794
1112
  agent,
1113
+ activeSelfContextId,
795
1114
  selfContexts,
796
1115
  abortSignal: abortController.signal,
797
1116
  media: {
package/dist/index.d.ts CHANGED
@@ -6,5 +6,5 @@ export { SessionManager } from './session-manager.js';
6
6
  export { DEFAULT_MEDIA_CACHE_DIR, getCodexImagePath, getMessageAttachments, inferUploadMimeType, isAnthropicImageAttachment, materializeAttachment, materializeMessageMedia, resolveAttachmentMimeType, sendMediaFileMessage, toAnthropicImageBlock, uploadMediaFile, } from './media.js';
7
7
  export type { AnthropicImageBlock, AnthropicImageMimeType, MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions, } from './media.js';
8
8
  export type { SessionConfig, Session } from './session-manager.js';
9
- export type { AgentContext, CanonContactRequest, CanonMessage, CanonConversation, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
10
- export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
9
+ export type { AgentContext, CanonGroupContext, CanonKnownRecentParticipant, CanonMembershipChange, CanonContactRequest, CanonMessage, CanonConversation, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
10
+ export type { CanonAgentOptions, ContactAddedHandler, ContactRemovedHandler, ContactRequestHandler, MessageHandler, MessageHandlerContext, ProgressMessageOptions, ProgressMessageResult, ReachOutOptions, ReachOutResult, RuntimePrimitiveContext, RuntimePrimitiveHandler, RuntimePrimitiveHandlers, SessionInfo, SessionOptions, DeliveryMode, } from './types.js';
@@ -1,4 +1,4 @@
1
- import { type AgentContext, type CanonClient, type ContactAddedPayload, type ContactApprovedPayload, type ContactRemovedPayload, type ContactRequestPayload } from '@canonmsg/core';
1
+ import { type AgentContext, type CanonClient, type ContactAddedPayload, type ContactApprovedPayload, type ContactRemovedPayload, type ContactRequestPayload, type ConversationUpdatedPayload } from '@canonmsg/core';
2
2
  import { Debouncer } from './debouncer.js';
3
3
  /**
4
4
  * Wraps @canonmsg/core's CanonStream with SDK-specific features:
@@ -15,6 +15,7 @@ export declare class RealtimeManager {
15
15
  private onContactApproved;
16
16
  private onContactAdded;
17
17
  private onContactRemoved;
18
+ private onConversationUpdated;
18
19
  private onConnected;
19
20
  private onDisconnected;
20
21
  constructor(apiKey: string, debouncer: Debouncer, agentId: string, streamUrl?: string, apiClient?: CanonClient);
@@ -27,6 +28,7 @@ export declare class RealtimeManager {
27
28
  onContactAdded?: (payload: ContactAddedPayload) => void;
28
29
  onContactRemoved?: (payload: ContactRemovedPayload) => void;
29
30
  }): void;
31
+ setConversationUpdatedHandler(cb: (payload: ConversationUpdatedPayload) => void): void;
30
32
  setConnectionHandlers(handlers: {
31
33
  onConnected?: () => void;
32
34
  onDisconnected?: () => void;
package/dist/realtime.js CHANGED
@@ -14,6 +14,7 @@ export class RealtimeManager {
14
14
  onContactApproved = null;
15
15
  onContactAdded = null;
16
16
  onContactRemoved = null;
17
+ onConversationUpdated = null;
17
18
  onConnected = null;
18
19
  onDisconnected = null;
19
20
  constructor(apiKey, debouncer, agentId, streamUrl, apiClient) {
@@ -30,6 +31,7 @@ export class RealtimeManager {
30
31
  const message = {
31
32
  id: m.id,
32
33
  senderId: m.senderId,
34
+ ...(m.senderName ? { senderName: m.senderName } : {}),
33
35
  senderType: m.senderType ?? 'human',
34
36
  isOwner: m.isOwner ?? false,
35
37
  contentType: m.contentType ?? 'text',
@@ -67,6 +69,9 @@ export class RealtimeManager {
67
69
  onContactRemoved: (payload) => {
68
70
  this.onContactRemoved?.(payload);
69
71
  },
72
+ onConversationUpdated: (payload) => {
73
+ this.onConversationUpdated?.(payload);
74
+ },
70
75
  onConnected: () => {
71
76
  // Reset backoff is handled internally by CanonStream
72
77
  this.onConnected?.();
@@ -91,6 +96,9 @@ export class RealtimeManager {
91
96
  this.onContactAdded = handlers.onContactAdded ?? null;
92
97
  this.onContactRemoved = handlers.onContactRemoved ?? null;
93
98
  }
99
+ setConversationUpdatedHandler(cb) {
100
+ this.onConversationUpdated = cb;
101
+ }
94
102
  setConnectionHandlers(handlers) {
95
103
  this.onConnected = handlers.onConnected ?? null;
96
104
  this.onDisconnected = handlers.onDisconnected ?? null;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AddMemberResult, AgentClientType, CanonRuntimeDescriptor, CanonMessage, CanonConversation, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonMessage, CanonConversation, CanonRuntimeActionDispatch, SendMessageOptions, SendContextualSelfContextInput, SessionConfig } from '@canonmsg/core';
1
+ export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeDescriptor, CanonRuntimePrimitiveId, CanonMessage, CanonConversation, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, SendMessageOptions, SendContextualSelfContextInput, SessionConfig } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -39,6 +39,8 @@ export interface MessageHandlerContext {
39
39
  history: CanonMessage[];
40
40
  conversationId: string;
41
41
  conversation: CanonConversation;
42
+ /** Lightweight group awareness, present for group conversations. */
43
+ groupContext?: CanonGroupContext;
42
44
  replyFinal: (text: string, options?: SendMessageOptions) => Promise<{
43
45
  messageId: string;
44
46
  }>;
@@ -67,8 +69,12 @@ export interface MessageHandlerContext {
67
69
  } | {
68
70
  targetUserId: string;
69
71
  }, text: string, options: Omit<import('@canonmsg/core').SendContextualMessageOptions, 'sourceConversationId' | 'targetConversationId' | 'targetUserId' | 'text'>) => Promise<import('@canonmsg/core').SendContextualMessageResult>;
72
+ /** Reach a contact card from this conversation; contextual reach-outs use this conversation as source. */
73
+ reachOut: (card: ContactCardPayload, options?: Omit<ReachOutOptions, 'sourceConversationId'>) => Promise<ReachOutResult>;
70
74
  /** Trusted agent identity & access context */
71
75
  agent: import('@canonmsg/core').AgentContext;
76
+ /** Active private self-context to continue for this turn, if Canon supplied one. */
77
+ activeSelfContextId: string | null;
72
78
  /** Canon-provided private context explaining this agent's cross-session actions. */
73
79
  selfContexts?: import('@canonmsg/core').CanonSelfContext[];
74
80
  /** Canon-managed local media access for the current conversation. */
@@ -117,6 +123,19 @@ export interface RuntimeControlHandlers {
117
123
  onStopAndDrop?: RuntimeSignalHandler;
118
124
  onNewSession?: RuntimeSignalHandler;
119
125
  }
126
+ export interface RuntimePrimitiveContext {
127
+ conversationId: string;
128
+ primitive: CanonRuntimePrimitiveId;
129
+ args: Record<string, string | boolean>;
130
+ rawText?: string;
131
+ alias?: string;
132
+ requestId?: string;
133
+ updatedAt?: number;
134
+ }
135
+ export type RuntimePrimitiveHandler = (context: RuntimePrimitiveContext) => void | Promise<void>;
136
+ export type RuntimePrimitiveHandlers = Partial<Record<CanonRuntimePrimitiveId, RuntimePrimitiveHandler>> & {
137
+ '*'?: RuntimePrimitiveHandler;
138
+ };
120
139
  export interface CanonAgentOptions {
121
140
  apiKey: string;
122
141
  baseUrl?: string;
@@ -135,6 +154,8 @@ export interface CanonAgentOptions {
135
154
  runtimeDescriptor?: import('@canonmsg/core').CanonRuntimeDescriptor;
136
155
  /** Optional Canon runtime signal handlers. Enables interrupt controls when provided. */
137
156
  runtimeControls?: RuntimeControlHandlers;
157
+ /** Optional typed runtime primitive handlers. Enables descriptor-backed command controls when provided. */
158
+ runtimePrimitives?: RuntimePrimitiveHandlers;
138
159
  /**
139
160
  * Enable RTDB session-state reporting. Off by default.
140
161
  * Turn-state reporting is automatic while handlers run.
@@ -156,12 +177,15 @@ export type ReachOutResult = {
156
177
  status: 'messaged';
157
178
  conversationId: string;
158
179
  messageId?: string;
180
+ selfContextId?: string;
159
181
  } | {
160
182
  status: 'requested';
161
183
  requestId: string | null;
184
+ deferredIntentId?: string | null;
162
185
  } | {
163
186
  status: 'pending';
164
187
  requestId: string | null;
188
+ deferredIntentId?: string | null;
165
189
  } | {
166
190
  status: 'setup_required';
167
191
  reason: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^0.16.0"
31
+ "@canonmsg/core": "^0.17.2"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"