@canonmsg/agent-sdk 0.2.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 +129 -0
- package/dist/api-client.d.ts +64 -0
- package/dist/api-client.js +257 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +73 -0
- package/dist/canon-agent.d.ts +48 -0
- package/dist/canon-agent.js +274 -0
- package/dist/debouncer.d.ts +13 -0
- package/dist/debouncer.js +64 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/polling.d.ts +16 -0
- package/dist/polling.js +78 -0
- package/dist/realtime.d.ts +23 -0
- package/dist/realtime.js +124 -0
- package/dist/session-manager.d.ts +61 -0
- package/dist/session-manager.js +180 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
const DEFAULT_CONTEXT_LIMIT = 50;
|
|
2
|
+
const DEFAULT_CONCURRENCY = 10;
|
|
3
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
|
|
4
|
+
const SWEEP_INTERVAL_MS = 60 * 1000; // 1 minute
|
|
5
|
+
/**
|
|
6
|
+
* Manages per-conversation sessions with:
|
|
7
|
+
* - In-memory message buffer per session
|
|
8
|
+
* - Serialized processing within a session (queue)
|
|
9
|
+
* - Parallel processing across sessions (bounded concurrency)
|
|
10
|
+
* - Idle session eviction
|
|
11
|
+
*/
|
|
12
|
+
export class SessionManager {
|
|
13
|
+
sessions = new Map();
|
|
14
|
+
queues = new Map();
|
|
15
|
+
activeCount = 0;
|
|
16
|
+
// Use Set<string> instead of Array for O(1) dedup and correct pop semantics (#1, #2)
|
|
17
|
+
pending = new Set();
|
|
18
|
+
processing = new Set();
|
|
19
|
+
// Per-session seen message ID sets for O(1) dedup (#9)
|
|
20
|
+
seenMessages = new Map();
|
|
21
|
+
// Track seeded sessions so seedHistory is idempotent (#7)
|
|
22
|
+
seededSessions = new Set();
|
|
23
|
+
sweepTimer = null;
|
|
24
|
+
contextLimit;
|
|
25
|
+
concurrency;
|
|
26
|
+
idleTimeoutMs;
|
|
27
|
+
constructor(config = {}) {
|
|
28
|
+
this.contextLimit = config.contextLimit ?? DEFAULT_CONTEXT_LIMIT;
|
|
29
|
+
this.concurrency = config.concurrency ?? DEFAULT_CONCURRENCY;
|
|
30
|
+
this.idleTimeoutMs = config.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
31
|
+
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS);
|
|
32
|
+
// Don't hold the process open for the sweeper
|
|
33
|
+
if (this.sweepTimer.unref)
|
|
34
|
+
this.sweepTimer.unref();
|
|
35
|
+
}
|
|
36
|
+
/** Get or create a session for a conversation */
|
|
37
|
+
getSession(conversationId) {
|
|
38
|
+
let session = this.sessions.get(conversationId);
|
|
39
|
+
if (!session) {
|
|
40
|
+
session = {
|
|
41
|
+
id: conversationId,
|
|
42
|
+
messages: [],
|
|
43
|
+
metadata: {},
|
|
44
|
+
lastActiveAt: Date.now(),
|
|
45
|
+
};
|
|
46
|
+
this.sessions.set(conversationId, session);
|
|
47
|
+
this.seenMessages.set(conversationId, new Set());
|
|
48
|
+
}
|
|
49
|
+
// Refresh lastActiveAt on every access to prevent premature eviction (#8)
|
|
50
|
+
session.lastActiveAt = Date.now();
|
|
51
|
+
return session;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Enqueue messages for processing in a specific session.
|
|
55
|
+
* Returns a promise that resolves when the handler completes for these messages.
|
|
56
|
+
*
|
|
57
|
+
* - Messages within the same session are serialized (queued).
|
|
58
|
+
* - Messages across different sessions run concurrently up to the concurrency limit.
|
|
59
|
+
*/
|
|
60
|
+
async enqueue(conversationId, messages, handler) {
|
|
61
|
+
const session = this.getSession(conversationId);
|
|
62
|
+
const seen = this.seenMessages.get(conversationId);
|
|
63
|
+
// Append new messages using O(1) Set lookup for dedup (#9)
|
|
64
|
+
for (const msg of messages) {
|
|
65
|
+
if (!seen.has(msg.id)) {
|
|
66
|
+
seen.add(msg.id);
|
|
67
|
+
session.messages.push(msg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Trim to context limit (keep most recent)
|
|
71
|
+
if (session.messages.length > this.contextLimit) {
|
|
72
|
+
session.messages = session.messages.slice(-this.contextLimit);
|
|
73
|
+
}
|
|
74
|
+
session.lastActiveAt = Date.now();
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
// Add to this session's queue
|
|
77
|
+
let queue = this.queues.get(conversationId);
|
|
78
|
+
if (!queue) {
|
|
79
|
+
queue = [];
|
|
80
|
+
this.queues.set(conversationId, queue);
|
|
81
|
+
}
|
|
82
|
+
queue.push({ messages, resolve, reject });
|
|
83
|
+
// Try to drain
|
|
84
|
+
this.drainSession(conversationId, handler);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
drainSession(conversationId, handler) {
|
|
88
|
+
// Already processing this session — the current run will pick up queued items
|
|
89
|
+
if (this.processing.has(conversationId))
|
|
90
|
+
return;
|
|
91
|
+
const queue = this.queues.get(conversationId);
|
|
92
|
+
if (!queue || queue.length === 0)
|
|
93
|
+
return;
|
|
94
|
+
// Check concurrency limit
|
|
95
|
+
if (this.activeCount >= this.concurrency) {
|
|
96
|
+
// Park this session — Set.add() is atomic and idempotent, eliminating
|
|
97
|
+
// the race between check and insert (#1)
|
|
98
|
+
this.pending.add(conversationId);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
// Take the next item
|
|
102
|
+
const item = queue.shift();
|
|
103
|
+
if (queue.length === 0)
|
|
104
|
+
this.queues.delete(conversationId);
|
|
105
|
+
this.activeCount++;
|
|
106
|
+
this.processing.add(conversationId);
|
|
107
|
+
const session = this.getSession(conversationId);
|
|
108
|
+
handler(session, item.messages)
|
|
109
|
+
.then(() => item.resolve())
|
|
110
|
+
.catch((err) => item.reject(err))
|
|
111
|
+
.finally(() => {
|
|
112
|
+
this.processing.delete(conversationId);
|
|
113
|
+
this.activeCount--;
|
|
114
|
+
// Continue draining this session if more items queued
|
|
115
|
+
const remaining = this.queues.get(conversationId);
|
|
116
|
+
if (remaining && remaining.length > 0) {
|
|
117
|
+
this.drainSession(conversationId, handler);
|
|
118
|
+
}
|
|
119
|
+
// Unpark the next pending session.
|
|
120
|
+
// Use Set iteration + explicit delete to get the correct conversation ID
|
|
121
|
+
// rather than shift() which could dequeue the wrong entry (#2)
|
|
122
|
+
if (this.pending.size > 0) {
|
|
123
|
+
const nextId = this.pending.values().next().value;
|
|
124
|
+
this.pending.delete(nextId);
|
|
125
|
+
this.drainSession(nextId, handler);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Seed a session with historical messages (e.g. fetched from API) */
|
|
130
|
+
seedHistory(conversationId, history) {
|
|
131
|
+
// Idempotent: only seed once per session, preventing races between
|
|
132
|
+
// concurrent batches that both observe an empty session (#7)
|
|
133
|
+
if (this.seededSessions.has(conversationId))
|
|
134
|
+
return;
|
|
135
|
+
const session = this.getSession(conversationId);
|
|
136
|
+
// Only seed if session is empty (first time)
|
|
137
|
+
if (session.messages.length > 0)
|
|
138
|
+
return;
|
|
139
|
+
this.seededSessions.add(conversationId);
|
|
140
|
+
const seen = this.seenMessages.get(conversationId);
|
|
141
|
+
const sliced = history.slice(-this.contextLimit);
|
|
142
|
+
session.messages = sliced;
|
|
143
|
+
for (const msg of sliced) {
|
|
144
|
+
seen.add(msg.id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/** Remove idle sessions */
|
|
148
|
+
sweep() {
|
|
149
|
+
const now = Date.now();
|
|
150
|
+
for (const [id, session] of this.sessions) {
|
|
151
|
+
if (now - session.lastActiveAt > this.idleTimeoutMs &&
|
|
152
|
+
!this.processing.has(id) &&
|
|
153
|
+
!this.queues.has(id) &&
|
|
154
|
+
!this.pending.has(id) // also skip sessions waiting for a concurrency slot (#8)
|
|
155
|
+
) {
|
|
156
|
+
this.sessions.delete(id);
|
|
157
|
+
this.seenMessages.delete(id);
|
|
158
|
+
this.seededSessions.delete(id);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
/** Number of active sessions */
|
|
163
|
+
get sessionCount() {
|
|
164
|
+
return this.sessions.size;
|
|
165
|
+
}
|
|
166
|
+
/** Clean up all state */
|
|
167
|
+
destroy() {
|
|
168
|
+
if (this.sweepTimer) {
|
|
169
|
+
clearInterval(this.sweepTimer);
|
|
170
|
+
this.sweepTimer = null;
|
|
171
|
+
}
|
|
172
|
+
this.sessions.clear();
|
|
173
|
+
this.queues.clear();
|
|
174
|
+
this.pending.clear();
|
|
175
|
+
this.processing.clear();
|
|
176
|
+
this.seenMessages.clear();
|
|
177
|
+
this.seededSessions.clear();
|
|
178
|
+
this.activeCount = 0;
|
|
179
|
+
}
|
|
180
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type { AgentClientType, CanonMessage, CanonConversation, AgentContext, SendMessageOptions, CreateConversationOptions, } from '@canonmsg/core';
|
|
2
|
+
import type { CanonMessage, CanonConversation } from '@canonmsg/core';
|
|
3
|
+
export type SDKMessage = CanonMessage;
|
|
4
|
+
export type SDKConversation = CanonConversation;
|
|
5
|
+
export interface SessionInfo {
|
|
6
|
+
/** Session ID (= conversationId) */
|
|
7
|
+
id: string;
|
|
8
|
+
/** All accumulated messages for this conversation (within the context limit), oldest first */
|
|
9
|
+
messages: SDKMessage[];
|
|
10
|
+
/** Arbitrary per-session state the agent can read/write across handler calls */
|
|
11
|
+
metadata: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface MessageHandlerContext {
|
|
14
|
+
messages: SDKMessage[];
|
|
15
|
+
history: SDKMessage[];
|
|
16
|
+
conversationId: string;
|
|
17
|
+
conversation: SDKConversation;
|
|
18
|
+
reply: (text: string) => Promise<{
|
|
19
|
+
messageId: string;
|
|
20
|
+
}>;
|
|
21
|
+
/** Soft-delete a message (agent must be the sender) */
|
|
22
|
+
deleteMessage: (messageId: string) => Promise<void>;
|
|
23
|
+
/** Mark conversation as read */
|
|
24
|
+
markAsRead: () => Promise<void>;
|
|
25
|
+
/** Leave this conversation */
|
|
26
|
+
leave: () => Promise<void>;
|
|
27
|
+
/** Toggle emoji reaction on a message */
|
|
28
|
+
react: (messageId: string, emoji: string) => Promise<void>;
|
|
29
|
+
/** Add a member to this conversation (requires owner/admin role) */
|
|
30
|
+
addMember: (userId: string) => Promise<void>;
|
|
31
|
+
/** Remove a member from this conversation (requires owner/admin role) */
|
|
32
|
+
removeMember: (userId: string) => Promise<void>;
|
|
33
|
+
/** Trusted agent identity & access context */
|
|
34
|
+
agent: import('@canonmsg/core').AgentContext;
|
|
35
|
+
/** Per-conversation session state. Present when sessions are enabled. */
|
|
36
|
+
session?: SessionInfo;
|
|
37
|
+
}
|
|
38
|
+
export type MessageHandler = (ctx: MessageHandlerContext) => Promise<void>;
|
|
39
|
+
export interface SessionOptions {
|
|
40
|
+
/** Enable per-conversation session management (default: false) */
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
/** Max messages retained per session (default: 50) */
|
|
43
|
+
contextLimit?: number;
|
|
44
|
+
/** Max sessions processing concurrently (default: 10) */
|
|
45
|
+
concurrency?: number;
|
|
46
|
+
/** Evict idle sessions after this many ms (default: 3600000 = 1h) */
|
|
47
|
+
idleTimeoutMs?: number;
|
|
48
|
+
}
|
|
49
|
+
export type DeliveryMode = 'auto' | 'sse' | 'realtime' | 'polling';
|
|
50
|
+
export interface CanonAgentOptions {
|
|
51
|
+
apiKey: string;
|
|
52
|
+
baseUrl?: string;
|
|
53
|
+
streamUrl?: string;
|
|
54
|
+
deliveryMode?: DeliveryMode;
|
|
55
|
+
pollingIntervalMs?: number;
|
|
56
|
+
debounceMs?: number;
|
|
57
|
+
historyLimit?: number;
|
|
58
|
+
/** Automatically mark conversations as read when handling messages (default: true) */
|
|
59
|
+
autoMarkRead?: boolean;
|
|
60
|
+
/** Per-conversation session management */
|
|
61
|
+
sessions?: SessionOptions;
|
|
62
|
+
/** Agent client type for capability detection. Defaults to 'generic'. */
|
|
63
|
+
clientType?: import('@canonmsg/core').AgentClientType;
|
|
64
|
+
/**
|
|
65
|
+
* Enable RTDB session state reporting. Off by default.
|
|
66
|
+
* When enabled, the SDK writes { cwd, isActive, ... } to
|
|
67
|
+
* /session-state/{convoId}/{agentId} so the Canon app can display
|
|
68
|
+
* the agent's active status and configuration.
|
|
69
|
+
*/
|
|
70
|
+
sessionState?: boolean;
|
|
71
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canonmsg/agent-sdk",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Canon Agent SDK — build AI agents that participate in Canon conversations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"dev": "tsc --watch",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18.0.0"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@canonmsg/core": "^0.2.2"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.0.0",
|
|
33
|
+
"typescript": "~5.7.0"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|