@canonmsg/agent-sdk 10.2.1 → 10.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.
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ const INTERACTION_TIMEOUT_MS = 5 * 60_000;
3
+ const cancelled = () => ({ kind: 'cancelled' });
4
+ /** Object property order does not change a request's content identity. */
5
+ function canonical(value) {
6
+ if (value === null || typeof value !== 'object') {
7
+ const result = JSON.stringify(value);
8
+ if (result === undefined)
9
+ throw new Error('Unsupported native request value');
10
+ return result;
11
+ }
12
+ if (Array.isArray(value))
13
+ return `[${value.map(canonical).join(',')}]`;
14
+ return `{${Object.entries(value).filter(([, entry]) => entry !== undefined)
15
+ .sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
16
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(',')}}`;
17
+ }
18
+ function digest(value) { return createHash('sha256').update(canonical(value)).digest('hex'); }
19
+ function validAnswers(value) {
20
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
21
+ && Object.values(value).every((entry) => entry !== null && typeof entry === 'object'
22
+ && Array.isArray(entry.answers)
23
+ && entry.answers.every((answer) => typeof answer === 'string'));
24
+ }
25
+ function validateResult(request, result) {
26
+ if (result?.kind === 'cancelled')
27
+ return cancelled();
28
+ if (request.kind === 'approval' && result?.kind === 'approval' && ['allow', 'deny'].includes(result.decision))
29
+ return result;
30
+ if (request.kind === 'input' && result?.kind === 'input' && validAnswers(result.answers))
31
+ return result;
32
+ return cancelled();
33
+ }
34
+ /** Audience-scoped durability around the shared request manager; it owns all Canon polling and cancellation. */
35
+ export function createWorkSessionInteractionBridge(options) {
36
+ const { manager, journal, hostId, conversationId, ownerId, onPendingChange } = options;
37
+ const active = new Map();
38
+ const revoked = new Set();
39
+ let pending = 0;
40
+ const pendingChange = (delta) => {
41
+ const wasPending = pending > 0;
42
+ pending += delta;
43
+ if (wasPending !== (pending > 0)) {
44
+ try {
45
+ onPendingChange?.(pending > 0);
46
+ }
47
+ catch { /* Observability cannot authorize or strand a native request. */ }
48
+ }
49
+ };
50
+ const link = (signal, controller) => {
51
+ const abort = () => controller.abort(signal.reason);
52
+ if (signal.aborted)
53
+ abort();
54
+ else
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ return () => signal.removeEventListener('abort', abort);
57
+ };
58
+ async function execute(request, id, fingerprint, signal) {
59
+ pendingChange(1);
60
+ try {
61
+ if (signal.aborted || revoked.has(id))
62
+ return cancelled();
63
+ const record = await journal.mutate((state) => {
64
+ const existing = state.interactions.find((entry) => entry.id === id);
65
+ if (existing)
66
+ return existing;
67
+ const next = { id, fingerprint, expiresAt: Date.now() + INTERACTION_TIMEOUT_MS };
68
+ state.interactions.push(next);
69
+ return next;
70
+ });
71
+ if (record.fingerprint !== fingerprint || record.expiresAt <= Date.now())
72
+ return cancelled();
73
+ if (signal.aborted) {
74
+ revoked.add(id);
75
+ await persistCancellation(id, fingerprint);
76
+ return cancelled();
77
+ }
78
+ if (record.result)
79
+ return validateResult(request, record.result);
80
+ const runtimeOptions = { requestId: id, expiresAt: record.expiresAt, signal };
81
+ let result;
82
+ if (request.kind === 'approval') {
83
+ const { toolName, toolInput, ...approvalOptions } = request.approval;
84
+ const reply = await manager.requestApproval(conversationId, toolName, toolInput, {
85
+ ...approvalOptions,
86
+ runtimeId: hostId,
87
+ ...(request.turnId ? { turnId: request.turnId } : {}),
88
+ responseUserId: ownerId,
89
+ ignoreSessionRules: true,
90
+ allowSessionRule: false,
91
+ }, runtimeOptions);
92
+ result = { kind: 'approval', decision: reply.decision === 'allow' && reply.respondedBy === ownerId ? 'allow' : 'deny' };
93
+ }
94
+ else {
95
+ const reply = await manager.request('input', conversationId, {
96
+ ...request.input, responseUserId: ownerId, ...(request.turnId ? { turnId: request.turnId } : {}),
97
+ }, runtimeOptions);
98
+ result = reply.status === 'submitted' && validAnswers(reply.answers)
99
+ ? { kind: 'input', answers: reply.answers }
100
+ : cancelled();
101
+ }
102
+ if (signal.aborted || record.expiresAt <= Date.now())
103
+ result = cancelled();
104
+ result = await journal.mutate((state) => {
105
+ const current = state.interactions.find((entry) => entry.id === id);
106
+ if (!current || current.fingerprint !== fingerprint)
107
+ throw new Error('Native interaction changed while pending');
108
+ current.result = signal.aborted || current.expiresAt <= Date.now() ? cancelled() : result;
109
+ return current.result;
110
+ });
111
+ // Native resolution can win while the result is being committed to disk.
112
+ if (signal.aborted || record.expiresAt <= Date.now()) {
113
+ revoked.add(id);
114
+ await persistCancellation(id, fingerprint);
115
+ return cancelled();
116
+ }
117
+ return structuredClone(result);
118
+ }
119
+ catch {
120
+ revoked.add(id);
121
+ // A failed durable write is never an authorization. Preserve the pending
122
+ // journal if even cancellation cannot be committed, and decline natively.
123
+ await persistCancellation(id, fingerprint).catch(() => { });
124
+ return cancelled();
125
+ }
126
+ finally {
127
+ pendingChange(-1);
128
+ }
129
+ }
130
+ async function persistCancellation(id, fingerprint) {
131
+ await journal.mutate((state) => {
132
+ const current = state.interactions.find((entry) => entry.id === id);
133
+ if (current?.fingerprint === fingerprint)
134
+ current.result = cancelled();
135
+ });
136
+ }
137
+ return {
138
+ request(request, { signal }) {
139
+ if (signal.aborted || !ownerId || !hostId || !conversationId || !request.nativeSessionId || !request.nativeRequestId)
140
+ return Promise.resolve(cancelled());
141
+ let id;
142
+ let fingerprint;
143
+ try {
144
+ id = `work_${digest([hostId, request.nativeSessionId, request.nativeRequestId, request.turnId ?? null])}`;
145
+ fingerprint = digest({ ownerId, conversationId, request });
146
+ if (revoked.has(id))
147
+ return Promise.resolve(cancelled());
148
+ const prior = journal.read().interactions.find((entry) => entry.id === id);
149
+ if (prior) {
150
+ if (prior.fingerprint !== fingerprint || prior.expiresAt <= Date.now())
151
+ return Promise.resolve(cancelled());
152
+ if (prior.result)
153
+ return Promise.resolve(validateResult(request, prior.result));
154
+ }
155
+ }
156
+ catch {
157
+ return Promise.resolve(cancelled());
158
+ }
159
+ const existing = active.get(id);
160
+ if (existing) {
161
+ if (existing.fingerprint !== fingerprint)
162
+ return Promise.resolve(cancelled());
163
+ const unlink = link(signal, existing.controller);
164
+ return existing.result.finally(unlink);
165
+ }
166
+ const controller = new AbortController();
167
+ const unlink = link(signal, controller);
168
+ const result = execute(request, id, fingerprint, controller.signal).finally(() => { active.delete(id); unlink(); });
169
+ active.set(id, { fingerprint, controller, result });
170
+ return result;
171
+ },
172
+ };
173
+ }
@@ -0,0 +1,36 @@
1
+ import { type ExclusiveJsonFileStore, type NativeSessionInteractionResult, type WorkSessionCompletion, type WorkSessionRequest, type WorkSessionSettings } from '@canonmsg/core';
2
+ export interface WorkSessionOperation {
3
+ request: WorkSessionRequest;
4
+ fingerprint: string;
5
+ phase: 'creating' | 'created' | 'attaching' | 'complete';
6
+ nativeSessionId?: string;
7
+ settings?: WorkSessionSettings;
8
+ completion?: WorkSessionCompletion;
9
+ acknowledged?: boolean;
10
+ /** Retain inaccessible operations for audit without blocking unrelated rooms. */
11
+ quarantined?: string;
12
+ }
13
+ export interface WorkSessionInteractionRecord {
14
+ id: string;
15
+ fingerprint: string;
16
+ expiresAt: number;
17
+ result?: NativeSessionInteractionResult;
18
+ }
19
+ export interface WorkSessionHostJournal {
20
+ schema: 'canon.work-session-host.v1';
21
+ environmentId: string;
22
+ agentId: string;
23
+ hostId: string;
24
+ operations: WorkSessionOperation[];
25
+ interactions: WorkSessionInteractionRecord[];
26
+ }
27
+ export type WorkSessionHostStore = ExclusiveJsonFileStore<WorkSessionHostJournal>;
28
+ export declare function createFileWorkSessionHostStore(path: string): WorkSessionHostStore;
29
+ /** Commit before advancing memory; simultaneous room events cannot overwrite one another. */
30
+ export declare function createWorkSessionJournal(store: WorkSessionHostStore, identity: Pick<WorkSessionHostJournal, 'environmentId' | 'agentId' | 'hostId'>): {
31
+ load(): Promise<void>;
32
+ read(): WorkSessionHostJournal;
33
+ mutate<T>(change: (journal: WorkSessionHostJournal) => T): Promise<T>;
34
+ close(): Promise<void>;
35
+ };
36
+ export type WorkSessionJournal = ReturnType<typeof createWorkSessionJournal>;
@@ -0,0 +1,62 @@
1
+ import { createExclusiveJsonFileStore, } from '@canonmsg/core';
2
+ export function createFileWorkSessionHostStore(path) {
3
+ return createExclusiveJsonFileStore(path, 'Work session host');
4
+ }
5
+ /** Commit before advancing memory; simultaneous room events cannot overwrite one another. */
6
+ export function createWorkSessionJournal(store, identity) {
7
+ let current;
8
+ let writes = Promise.resolve();
9
+ let closed = false;
10
+ return {
11
+ async load() {
12
+ const loaded = await store.load();
13
+ if (loaded) {
14
+ if (loaded.schema !== 'canon.work-session-host.v1'
15
+ || loaded.environmentId !== identity.environmentId || loaded.agentId !== identity.agentId
16
+ || loaded.hostId !== identity.hostId || !Array.isArray(loaded.operations)
17
+ || !Array.isArray(loaded.interactions)
18
+ || loaded.operations.some((entry) => !entry?.request?.requestId || !entry.fingerprint
19
+ || !['creating', 'created', 'attaching', 'complete'].includes(entry.phase)
20
+ || (entry.phase === 'complete' && !entry.completion))
21
+ || loaded.interactions.some((entry) => !entry?.id || !entry.fingerprint || !Number.isFinite(entry.expiresAt))) {
22
+ throw new Error('Work-session journal cannot be verified; retain it for recovery.');
23
+ }
24
+ current = structuredClone(loaded);
25
+ }
26
+ else {
27
+ const initial = { schema: 'canon.work-session-host.v1', ...identity, operations: [], interactions: [] };
28
+ await store.save(initial);
29
+ current = initial;
30
+ }
31
+ },
32
+ read() {
33
+ if (!current)
34
+ throw new Error('Work-session journal is not loaded.');
35
+ return structuredClone(current);
36
+ },
37
+ mutate(change) {
38
+ if (closed)
39
+ return Promise.reject(new Error('Work-session journal is closed.'));
40
+ let result;
41
+ const mutation = writes.then(async () => {
42
+ if (!current)
43
+ throw new Error('Work-session journal is not loaded.');
44
+ const next = structuredClone(current);
45
+ result = change(next);
46
+ await store.save(next);
47
+ current = next;
48
+ });
49
+ writes = mutation.catch(() => { });
50
+ return mutation.then(() => result);
51
+ },
52
+ async close() {
53
+ closed = true;
54
+ try {
55
+ await writes;
56
+ }
57
+ finally {
58
+ await store.close();
59
+ }
60
+ },
61
+ };
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/agent-sdk",
3
- "version": "10.2.1",
3
+ "version": "10.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",
@@ -28,7 +28,7 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@canonmsg/core": "^12.3.0"
31
+ "@canonmsg/core": "^12.5.0"
32
32
  },
33
33
  "publishConfig": {
34
34
  "access": "public"
@@ -38,5 +38,9 @@
38
38
  "typescript": "~5.7.0",
39
39
  "vitest": "^4.1.8"
40
40
  },
41
- "license": "MIT"
41
+ "license": "MIT",
42
+ "homepage": "https://canonmail.com/agents/build#agent-sdk",
43
+ "bugs": {
44
+ "url": "https://canonmail.com/support"
45
+ }
42
46
  }