@canonmsg/agent-sdk 2.0.0 → 2.1.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,7 +1,8 @@
1
- import { ApprovalManager, CanonClient, buildCanonGroupContext, createTurnOutputController, createRuntimeStatePublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeInputOutcome, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, selectActiveSelfContexts, } from '@canonmsg/core';
1
+ import { ApprovalManager, CanonClient, buildCanonGroupContext, createTurnOutputController, createRuntimeStatePublisher, diffCanonMemberIds, FINAL_MESSAGE_HANDOFF_MS, RUNTIME_NEW_SESSION_ACTION, RUNTIME_STOP_ACTION, RUNTIME_STOP_AND_DROP_ACTION, buildRuntimeCardOutcome, buildRuntimeInputOutcome, initRTDBAuth, rtdbRead, rtdbWrite, normalizeTurnMetadata, reachOutToCanonContact, resolveCanonReplyContext, resolveMessageActiveSelfContextId, resolveRuntimeProvenance, 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
+ import { buildRuntimeCardCreateArgs } from './runtime-card.js';
5
6
  import { materializeMessageMedia, materializeReplyContextMedia, sendMediaFileMessage, uploadMediaFile, } from './media.js';
6
7
  import { SessionManager } from './session-manager.js';
7
8
  const AGENT_RUNTIME_HEARTBEAT_MS = 30_000;
@@ -23,6 +24,17 @@ const DEFAULT_SDK_RUNTIME_DESCRIPTOR = {
23
24
  runtimeControls: [],
24
25
  supportsInterrupt: false,
25
26
  streamingTextMode: 'snapshot',
27
+ runtimeCards: {
28
+ rich: {
29
+ schema: 'canon.card.v1',
30
+ lifecycle: 'blocking_requires_action',
31
+ responder: 'agent_owner',
32
+ result: 'action_or_values',
33
+ maxTimeoutMs: 30 * 60_000,
34
+ blockKinds: ['summary', 'metricGrid', 'chart', 'table', 'list', 'callout', 'actions'],
35
+ native: true,
36
+ },
37
+ },
26
38
  };
27
39
  const STANDARD_PRIMITIVE_COMMANDS = {
28
40
  'runtime.status': {
@@ -146,6 +158,13 @@ function safeRuntimeInputId(value, kind) {
146
158
  ? normalized
147
159
  : `${kind}_${randomUUID()}`;
148
160
  }
161
+ function safeRuntimeCardId(value) {
162
+ const raw = value?.trim() || `card_${randomUUID()}`;
163
+ const normalized = raw.replace(/[.#$\[\]\/\s]+/g, '_').slice(0, 80);
164
+ return RUNTIME_INPUT_ID_PATTERN.test(normalized)
165
+ ? normalized
166
+ : `card_${randomUUID()}`;
167
+ }
149
168
  function isRuntimePrimitiveId(value) {
150
169
  return value === 'runtime.status'
151
170
  || value === 'runtime.reasoning.set'
@@ -1512,6 +1531,193 @@ export class CanonAgent {
1512
1531
  if (abortController.signal.aborted || isAbortLikeError(error)) {
1513
1532
  throw error;
1514
1533
  }
1534
+ if (!requestCreated) {
1535
+ throw error;
1536
+ }
1537
+ shouldPersistTurnState = false;
1538
+ return result;
1539
+ }
1540
+ };
1541
+ const sendCard = async (request) => {
1542
+ throwIfAborted();
1543
+ const cardId = safeRuntimeCardId(request.cardId ?? request.card.cardId);
1544
+ const explicitExpiresAt = request.expiresAt instanceof Date
1545
+ ? request.expiresAt.getTime()
1546
+ : typeof request.expiresAt === 'number'
1547
+ ? request.expiresAt
1548
+ : typeof request.expiresAt === 'string'
1549
+ ? Date.parse(request.expiresAt)
1550
+ : null;
1551
+ const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1552
+ const expiresAtMs = explicitExpiresAt && Number.isFinite(explicitExpiresAt)
1553
+ ? explicitExpiresAt
1554
+ : Date.now() + timeoutMs;
1555
+ // Fire-and-forget: post the durable card and return. The backend treats an
1556
+ // action-less card as display (no pending state, no response expected).
1557
+ await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({ request, conversationId, cardId, fallbackTurnId: turnId, expiresAtMs }));
1558
+ try {
1559
+ await turnOutput.addBlock({
1560
+ id: `card:${cardId}`,
1561
+ kind: 'status',
1562
+ status: 'completed',
1563
+ title: request.card.title,
1564
+ summary: request.card.template ?? 'runtime card',
1565
+ });
1566
+ }
1567
+ catch { }
1568
+ return { status: 'displayed', cardId };
1569
+ };
1570
+ const requestCard = async (request) => {
1571
+ throwIfAborted();
1572
+ // Action-less cards are fire-and-forget display/report cards — never block.
1573
+ const hasActions = Array.isArray(request.card.blocks)
1574
+ && request.card.blocks.some((block) => block.kind === 'actions');
1575
+ if (!hasActions)
1576
+ return sendCard(request);
1577
+ const cardId = safeRuntimeCardId(request.cardId ?? request.card.cardId);
1578
+ const explicitExpiresAt = request.expiresAt instanceof Date
1579
+ ? request.expiresAt.getTime()
1580
+ : typeof request.expiresAt === 'number'
1581
+ ? request.expiresAt
1582
+ : typeof request.expiresAt === 'string'
1583
+ ? Date.parse(request.expiresAt)
1584
+ : null;
1585
+ const timeoutMs = Math.max(1_000, request.timeoutMs ?? DEFAULT_RUNTIME_INPUT_TIMEOUT_MS);
1586
+ const expiresAtMs = explicitExpiresAt && Number.isFinite(explicitExpiresAt)
1587
+ ? explicitExpiresAt
1588
+ : Date.now() + timeoutMs;
1589
+ let result = { status: 'timeout', cardId };
1590
+ let requestCreated = false;
1591
+ let requestResolved = false;
1592
+ const cancelPendingRequest = async () => {
1593
+ if (!requestCreated || requestResolved)
1594
+ return;
1595
+ try {
1596
+ await this.apiClient.consumeRuntimeCardResponse({
1597
+ conversationId,
1598
+ cardId,
1599
+ cancel: true,
1600
+ });
1601
+ requestResolved = true;
1602
+ }
1603
+ catch { }
1604
+ };
1605
+ shouldPersistTurnState = true;
1606
+ try {
1607
+ await this.apiClient.createRuntimeCardRequest(buildRuntimeCardCreateArgs({ request, conversationId, cardId, fallbackTurnId: turnId, expiresAtMs }));
1608
+ requestCreated = true;
1609
+ try {
1610
+ await turnOutput.addBlock({
1611
+ id: `card:${cardId}`,
1612
+ kind: 'input',
1613
+ status: 'pending',
1614
+ title: request.card.title,
1615
+ summary: request.card.template ?? 'runtime card',
1616
+ });
1617
+ await turnOutput.waitingInput();
1618
+ }
1619
+ catch { }
1620
+ await writeTurn('waiting_input');
1621
+ try {
1622
+ await this.apiClient.setTyping(conversationId, false);
1623
+ }
1624
+ catch { }
1625
+ while (Date.now() < expiresAtMs) {
1626
+ throwIfAborted();
1627
+ try {
1628
+ const response = await this.apiClient.consumeRuntimeCardResponse({
1629
+ conversationId,
1630
+ cardId,
1631
+ });
1632
+ if (response.status === 'submitted') {
1633
+ requestResolved = true;
1634
+ result = {
1635
+ status: 'submitted',
1636
+ cardId,
1637
+ ...(response.actionId ? { actionId: response.actionId } : {}),
1638
+ ...(response.values ? { values: response.values } : {}),
1639
+ };
1640
+ break;
1641
+ }
1642
+ if (response.status === 'cancelled' || response.status === 'timeout') {
1643
+ requestResolved = true;
1644
+ result = { status: response.status, cardId };
1645
+ break;
1646
+ }
1647
+ }
1648
+ catch {
1649
+ // Keep waiting; the control path is the authoritative source and
1650
+ // transient consume failures should not leak response values.
1651
+ }
1652
+ await sleepWithAbort(Math.min(RUNTIME_INPUT_POLL_MS, Math.max(1, expiresAtMs - Date.now())), abortController.signal);
1653
+ }
1654
+ if (!requestResolved) {
1655
+ try {
1656
+ const response = await this.apiClient.consumeRuntimeCardResponse({
1657
+ conversationId,
1658
+ cardId,
1659
+ });
1660
+ if (response.status === 'submitted') {
1661
+ result = {
1662
+ status: 'submitted',
1663
+ cardId,
1664
+ ...(response.actionId ? { actionId: response.actionId } : {}),
1665
+ ...(response.values ? { values: response.values } : {}),
1666
+ };
1667
+ }
1668
+ else if (response.status === 'cancelled' || response.status === 'timeout') {
1669
+ result = { status: response.status, cardId };
1670
+ }
1671
+ }
1672
+ catch { }
1673
+ requestResolved = true;
1674
+ }
1675
+ // The interactive path never yields 'displayed' (that returns early via
1676
+ // sendCard); narrow for buildRuntimeCardOutcome's resolution status.
1677
+ const resolutionStatus = result.status === 'displayed' ? 'timeout' : result.status;
1678
+ const outcome = buildRuntimeCardOutcome(cardId, resolutionStatus, {
1679
+ reason: resolutionStatus,
1680
+ });
1681
+ await this.apiClient.sendMessage(conversationId, outcome.text, {
1682
+ metadata: {
1683
+ ...outcome.metadata,
1684
+ turnId: request.turnId ?? turnId,
1685
+ turnSemantics: 'control',
1686
+ replyBehavior: 'suppress_auto_reply',
1687
+ },
1688
+ });
1689
+ throwIfAborted();
1690
+ shouldPersistTurnState = false;
1691
+ try {
1692
+ await turnOutput.completeBlock(`card:${cardId}`, {
1693
+ summary: `Card ${result.status}`,
1694
+ });
1695
+ }
1696
+ catch { }
1697
+ try {
1698
+ await this.apiClient.setTyping(conversationId, true, 'thinking');
1699
+ }
1700
+ catch { }
1701
+ await setLiveState('thinking', 'Thinking...', 'thinking');
1702
+ return result;
1703
+ }
1704
+ catch (error) {
1705
+ const shouldSendInterruptedOutcome = requestCreated && !requestResolved;
1706
+ await cancelPendingRequest();
1707
+ if (abortController.signal.aborted || isAbortLikeError(error)) {
1708
+ if (shouldSendInterruptedOutcome) {
1709
+ const outcome = buildRuntimeCardOutcome(cardId, 'cancelled', { reason: 'interrupted' });
1710
+ await this.apiClient.sendMessage(conversationId, outcome.text, {
1711
+ metadata: {
1712
+ ...outcome.metadata,
1713
+ turnId: request.turnId ?? turnId,
1714
+ turnSemantics: 'control',
1715
+ replyBehavior: 'suppress_auto_reply',
1716
+ },
1717
+ }).catch(() => { });
1718
+ }
1719
+ throw error;
1720
+ }
1515
1721
  shouldPersistTurnState = false;
1516
1722
  return result;
1517
1723
  }
@@ -1580,6 +1786,8 @@ export class CanonAgent {
1580
1786
  provenance,
1581
1787
  requestApproval,
1582
1788
  requestRuntimeInput,
1789
+ requestCard,
1790
+ sendCard,
1583
1791
  abortSignal: abortController.signal,
1584
1792
  media: {
1585
1793
  materialize: (message = hydratedMessages[hydratedMessages.length - 1], options) => {
@@ -0,0 +1,29 @@
1
+ import type { RuntimeCardNativeMetadata, RuntimeCardV1 } from '@canonmsg/core';
2
+ import type { RuntimeCardRequest } from './types';
3
+ /** Arguments passed to `CanonClient.createRuntimeCardRequest`. */
4
+ export interface RuntimeCardCreateArgs {
5
+ conversationId: string;
6
+ card: RuntimeCardV1;
7
+ cardId: string;
8
+ expiresAt: number;
9
+ responseUserId?: string;
10
+ runtimeId?: string;
11
+ turnId?: string;
12
+ native?: RuntimeCardNativeMetadata;
13
+ }
14
+ /**
15
+ * Build the `createRuntimeCardRequest` payload shared by `sendCard` (display)
16
+ * and `requestCard` (interactive).
17
+ *
18
+ * `responseUserId` is OMITTED unless the developer supplied one, so the Canon
19
+ * backend infers a reachable responder (the owner when they are a conversation
20
+ * member, otherwise the sole other member). Pre-filling the owner here would
21
+ * break agent-to-user DMs where the owner is not a member of the conversation.
22
+ */
23
+ export declare function buildRuntimeCardCreateArgs(input: {
24
+ request: RuntimeCardRequest;
25
+ conversationId: string;
26
+ cardId: string;
27
+ fallbackTurnId?: string;
28
+ expiresAtMs: number;
29
+ }): RuntimeCardCreateArgs;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Build the `createRuntimeCardRequest` payload shared by `sendCard` (display)
3
+ * and `requestCard` (interactive).
4
+ *
5
+ * `responseUserId` is OMITTED unless the developer supplied one, so the Canon
6
+ * backend infers a reachable responder (the owner when they are a conversation
7
+ * member, otherwise the sole other member). Pre-filling the owner here would
8
+ * break agent-to-user DMs where the owner is not a member of the conversation.
9
+ */
10
+ export function buildRuntimeCardCreateArgs(input) {
11
+ const { request, conversationId, cardId, fallbackTurnId, expiresAtMs } = input;
12
+ return {
13
+ conversationId,
14
+ cardId,
15
+ card: { ...request.card, cardId },
16
+ expiresAt: expiresAtMs,
17
+ ...(request.responseUserId ? { responseUserId: request.responseUserId } : {}),
18
+ ...(request.runtimeId ? { runtimeId: request.runtimeId } : {}),
19
+ turnId: request.turnId ?? fallbackTurnId,
20
+ ...(request.native ? { native: request.native } : {}),
21
+ };
22
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
- import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, SendMessageOptions, SendContextualSelfContextInput, SessionConfig, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
1
+ export type { AddMemberResult, AgentClientType, CanonGroupContext, CanonRuntimeActivityItem, CanonRuntimeActivityKind, CanonRuntimeActivityStatus, CanonRuntimeDescriptor, CanonRuntimeFact, CanonRuntimeFactGroup, CanonRuntimePrimitiveId, CanonRuntimeProvenance, CanonMessage, CanonConversation, CanonReplyContext, CanonContact, CanonContactRequest, CanonResolveAdmissionResult, ContactAddedPayload, ContactRemovedPayload, ContactSource, AgentContext, ResolvedAdmissionState, ResolvedAdmissionTargetSummary, ResolvedTargetAdmissionPayload, CanonSelfContext, SendContextualMessageOptions, SendContextualMessageResult, SendContextualSelfContextInput, SendMessageOptions, SessionConfig, CreateConversationOptions, TurnLifecycleState, TurnOutputBlock, TurnOutputBlockInput, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalRequestMetadata, ApprovalRisk, ApprovalReplyMetadata, ApprovalOutcomeMetadata, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SessionRule, ApprovalResult, } from '@canonmsg/core';
2
+ import type { AddMemberResult, CanonGroupContext, CanonMessage, CanonConversation, CanonReplyContext, ContactCardPayload, CanonRuntimeActionDispatch, CanonRuntimePrimitiveId, CanonRuntimeProvenance, ApprovalNativeRequestMetadata, ApprovalRequestCategory, ApprovalRequestDetail, ApprovalResult, ApprovalRisk, RuntimeInputChoice, RuntimeInputAnswers, RuntimeInputKind, RuntimeInputNativeMetadata, RuntimeInputQuestion, RuntimeCardNativeMetadata, RuntimeCardV1, SendMessageOptions, SendContextualSelfContextInput, SessionConfig, TurnOutputBlock, TurnOutputBlockInput } from '@canonmsg/core';
3
3
  import type { MaterializeMediaOptions, MaterializedCanonAttachment, ReplyWithFileOptions, UploadMediaFileOptions } from './media.js';
4
4
  export interface ProgressMessageOptions extends SendMessageOptions {
5
5
  /**
@@ -85,6 +85,22 @@ export interface RuntimeInputResult {
85
85
  answers?: RuntimeInputAnswers;
86
86
  inputId: string;
87
87
  }
88
+ export interface RuntimeCardRequest {
89
+ card: RuntimeCardV1;
90
+ cardId?: string;
91
+ expiresAt?: string | number | Date;
92
+ responseUserId?: string;
93
+ timeoutMs?: number;
94
+ runtimeId?: string;
95
+ turnId?: string;
96
+ native?: RuntimeCardNativeMetadata;
97
+ }
98
+ export interface RuntimeCardResult {
99
+ status: 'submitted' | 'cancelled' | 'timeout' | 'displayed';
100
+ cardId: string;
101
+ actionId?: string;
102
+ values?: Record<string, unknown>;
103
+ }
88
104
  export interface MessageHandlerContext {
89
105
  messages: CanonMessage[];
90
106
  history: CanonMessage[];
@@ -144,6 +160,19 @@ export interface MessageHandlerContext {
144
160
  * never persisted in Canon message metadata.
145
161
  */
146
162
  requestRuntimeInput: (request: RuntimeInputRequest) => Promise<RuntimeInputResult>;
163
+ /**
164
+ * Ask the agent owner to review/respond to a generic rich card. The visible
165
+ * card document is redacted for presentation; raw response values return
166
+ * only to the calling runtime through Canon's control path.
167
+ */
168
+ requestCard: (request: RuntimeCardRequest) => Promise<RuntimeCardResult>;
169
+ /**
170
+ * Post a fire-and-forget display/report card and return immediately with
171
+ * `{ status: 'displayed' }` — no waiting-input lifecycle. Use requestCard for
172
+ * cards that expect a response. (requestCard also auto-detects action-less
173
+ * cards and delegates here.)
174
+ */
175
+ sendCard: (request: RuntimeCardRequest) => Promise<RuntimeCardResult>;
147
176
  /** Canon-managed local media access for the current conversation. */
148
177
  media: {
149
178
  materialize: (message?: CanonMessage, options?: Omit<MaterializeMediaOptions, 'agentId' | 'conversationId' | 'messageId'>) => Promise<MaterializedCanonAttachment[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^1.0.0"
31
+ "@canonmsg/core": "^1.2.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"