@borgee/agents-host 0.2.2 → 0.2.26

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.
Files changed (76) hide show
  1. package/README.md +184 -21
  2. package/dist/agents-host-supervisor.d.ts +7 -5
  3. package/dist/agents-host-supervisor.js +24 -4
  4. package/dist/agents-host.d.ts +89 -15
  5. package/dist/agents-host.js +2099 -141
  6. package/dist/chat/chat-control-plane.d.ts +13 -2
  7. package/dist/chat/sdk-chat-control-plane.d.ts +14 -3
  8. package/dist/chat/sdk-chat-control-plane.js +54 -2
  9. package/dist/cli-args.d.ts +46 -5
  10. package/dist/cli-args.js +313 -32
  11. package/dist/cli.d.ts +9 -0
  12. package/dist/cli.js +112 -5
  13. package/dist/compatibility-gates.d.ts +35 -0
  14. package/dist/compatibility-gates.js +127 -0
  15. package/dist/config.d.ts +1 -0
  16. package/dist/config.js +23 -5
  17. package/dist/connections-state-store.d.ts +81 -0
  18. package/dist/connections-state-store.js +228 -0
  19. package/dist/context/injection.d.ts +109 -0
  20. package/dist/context/injection.js +350 -0
  21. package/dist/context/prompt.d.ts +4 -1
  22. package/dist/context/prompt.js +170 -1
  23. package/dist/context/turn-preparation.d.ts +9 -0
  24. package/dist/context/turn-preparation.js +106 -0
  25. package/dist/debug.d.ts +44 -0
  26. package/dist/debug.js +135 -0
  27. package/dist/gateway/localhost-gateway.d.ts +52 -0
  28. package/dist/gateway/localhost-gateway.js +857 -0
  29. package/dist/index.js +7 -5
  30. package/dist/local-config.d.ts +4 -1
  31. package/dist/local-config.js +24 -7
  32. package/dist/managed-daemon-log.d.ts +34 -0
  33. package/dist/managed-daemon-log.js +261 -0
  34. package/dist/managed-daemon.d.ts +220 -0
  35. package/dist/managed-daemon.js +1601 -0
  36. package/dist/policy/authorization-audit.d.ts +63 -0
  37. package/dist/policy/authorization-audit.js +94 -0
  38. package/dist/policy/copilot-permission.d.ts +15 -0
  39. package/dist/policy/copilot-permission.js +193 -0
  40. package/dist/policy/gateway-authorization.d.ts +42 -0
  41. package/dist/policy/gateway-authorization.js +162 -0
  42. package/dist/providers/awaiting-user.d.ts +12 -0
  43. package/dist/providers/awaiting-user.js +151 -0
  44. package/dist/providers/claude/adapter.d.ts +3 -1
  45. package/dist/providers/claude/adapter.js +8 -12
  46. package/dist/providers/claude/cli-client.d.ts +12 -5
  47. package/dist/providers/claude/cli-client.js +184 -37
  48. package/dist/providers/claude/session-store.d.ts +1 -0
  49. package/dist/providers/codex/adapter.d.ts +11 -0
  50. package/dist/providers/codex/adapter.js +19 -0
  51. package/dist/providers/codex/cli-client.d.ts +103 -0
  52. package/dist/providers/codex/cli-client.js +1133 -0
  53. package/dist/providers/codex/project-doc.d.ts +3 -0
  54. package/dist/providers/codex/project-doc.js +66 -0
  55. package/dist/providers/codex/session-store.d.ts +38 -0
  56. package/dist/providers/codex/session-store.js +150 -0
  57. package/dist/providers/copilot/adapter.d.ts +3 -1
  58. package/dist/providers/copilot/adapter.js +8 -12
  59. package/dist/providers/copilot/cli-client.d.ts +20 -2
  60. package/dist/providers/copilot/cli-client.js +251 -71
  61. package/dist/providers/copilot/session-store.d.ts +1 -0
  62. package/dist/providers/create-provider.d.ts +11 -2
  63. package/dist/providers/create-provider.js +131 -12
  64. package/dist/run.d.ts +1 -0
  65. package/dist/run.js +5 -2
  66. package/dist/state-paths.d.ts +13 -1
  67. package/dist/state-paths.js +84 -3
  68. package/dist/task-thread-resolution.d.ts +10 -0
  69. package/dist/task-thread-resolution.js +48 -0
  70. package/dist/types.d.ts +174 -1
  71. package/dist/visible-mentions.d.ts +3 -0
  72. package/dist/visible-mentions.js +15 -0
  73. package/package.json +19 -17
  74. package/skills/borgee-agent/SKILL.md +33 -0
  75. package/skills/borgee-agent/borgee-agent.mjs +473 -0
  76. package/skills/borgee-agent/borgee-agent.py +409 -0
@@ -1,33 +1,277 @@
1
- import { chmod, mkdir } from 'node:fs/promises';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, link, mkdir, readFile, readdir, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
2
4
  import { SdkChatControlPlane } from './chat/sdk-chat-control-plane.js';
5
+ import { HostLogger, summarizeError } from './debug.js';
3
6
  import { createDurableCursorStore } from './durable-cursor-store.js';
7
+ import { COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE, CONTEXT_INJECTION_COMPATIBILITY_GATE, LOCALHOST_GATEWAY_COMPATIBILITY_GATE, POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE, resolveInternalPolicyMode, resolveInternalCompatibilityGates, SKILL_RUNTIME_COMPATIBILITY_GATE, TOKEN_BINDING_COMPATIBILITY_GATE, } from './compatibility-gates.js';
8
+ import { createLocalhostGatewayController, LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS, } from './gateway/localhost-gateway.js';
9
+ import { extractTaskIdFromTaskAssignmentContent } from './context/injection.js';
10
+ import { AuthorizationAuditSink, } from './policy/authorization-audit.js';
4
11
  import { createProvider } from './providers/create-provider.js';
12
+ import { resolveSharedProtocolKickoffDecisionPath, resolveSharedProtocolStatusPath, } from './state-paths.js';
13
+ import { appendVisibleMention as appendVisibleBodyMention, extractVisibleMentionIds as extractCanonicalMentionIds, } from './visible-mentions.js';
14
+ import { waitForTaskForThread } from './task-thread-resolution.js';
5
15
  const PRIVATE_STATE_ROOT_MODE = 0o700;
6
16
  const STREAM_PROGRESS_EDIT_THROTTLE_MS = 150;
17
+ const PROTOCOL_STATE_DIRNAME = 'protocol-state';
18
+ const PROTOCOL_PROVIDER_TURN_TIMEOUT_MS = 120_000;
19
+ const PROTOCOL_KICKOFF_DECISION_TIMEOUT_MS = PROTOCOL_PROVIDER_TURN_TIMEOUT_MS;
20
+ const PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS = 50;
21
+ const PROTOCOL_KICKOFF_ROUND_LIMIT = 20;
22
+ const COLLABORATION_LATE_SEND_GRACE_MS = 5_000;
7
23
  async function ensurePrivateStateRoot(path) {
8
24
  await mkdir(path, { recursive: true, mode: PRIVATE_STATE_ROOT_MODE });
9
25
  await chmod(path, PRIVATE_STATE_ROOT_MODE);
10
26
  }
27
+ function matchesProtocolStateIdentity(left, right) {
28
+ return (left.anchorMessageId === right.anchorMessageId &&
29
+ left.issuerId === right.issuerId &&
30
+ left.participantIds[0] === right.participantIds[0] &&
31
+ left.participantIds[1] === right.participantIds[1]);
32
+ }
33
+ function shouldAdoptPeerProtocolState(current, candidate, selfAgentId) {
34
+ if (!matchesProtocolStateIdentity(current, candidate)) {
35
+ return false;
36
+ }
37
+ if (candidate.currentTurnIndex !== current.currentTurnIndex) {
38
+ return candidate.currentTurnIndex > current.currentTurnIndex;
39
+ }
40
+ if ((candidate.finished && !current.finished) || (!candidate.active && current.active)) {
41
+ return true;
42
+ }
43
+ if (!current.lastProtocolMessageId && candidate.lastProtocolMessageId) {
44
+ return true;
45
+ }
46
+ if (current.expectedNextSpeakerId !== selfAgentId &&
47
+ candidate.expectedNextSpeakerId === selfAgentId) {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+ async function writePrivateJsonFile(path, value, options) {
53
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
54
+ const content = `${JSON.stringify(value, null, 2)}\n`;
55
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
56
+ await writeFile(temporaryPath, content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
57
+ try {
58
+ if (options?.exclusive) {
59
+ await link(temporaryPath, path);
60
+ await unlink(temporaryPath);
61
+ return;
62
+ }
63
+ await rename(temporaryPath, path);
64
+ }
65
+ catch (error) {
66
+ await unlink(temporaryPath).catch(() => { });
67
+ throw error;
68
+ }
69
+ }
70
+ class DefaultHostRuntime {
71
+ config;
72
+ provider;
73
+ controlPlane;
74
+ ensureStateRoot;
75
+ gateway;
76
+ authorizationAuditSink;
77
+ logger;
78
+ constructor(config, deps) {
79
+ this.config = config;
80
+ this.logger = deps.logger;
81
+ const compatibilityGates = resolveInternalCompatibilityGates();
82
+ const contextInjectionGateEnabled = compatibilityGates.has(CONTEXT_INJECTION_COMPATIBILITY_GATE);
83
+ const skillRuntimeGateEnabled = compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
84
+ const localhostGatewayGateEnabled = contextInjectionGateEnabled &&
85
+ skillRuntimeGateEnabled &&
86
+ compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
87
+ const tokenBindingGateEnabled = compatibilityGates.has(TOKEN_BINDING_COMPATIBILITY_GATE);
88
+ const policyAuditGateEnabled = compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
89
+ const policyMode = resolveInternalPolicyMode(policyAuditGateEnabled);
90
+ const collaborationEnabled = localhostGatewayGateEnabled &&
91
+ compatibilityGates.has(COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE) &&
92
+ policyAuditGateEnabled &&
93
+ policyMode === 'enforce';
94
+ this.authorizationAuditSink = policyAuditGateEnabled
95
+ ? new AuthorizationAuditSink({
96
+ stateRootDir: config.stateRootDir,
97
+ logger: deps.logger,
98
+ })
99
+ : undefined;
100
+ this.controlPlane =
101
+ deps.borgee ??
102
+ new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined, {
103
+ cursorStore: createDurableCursorStore({
104
+ stateRootDir: config.stateRootDir,
105
+ }),
106
+ pluginId: `agents-host:${config.agent.provider}`,
107
+ });
108
+ this.gateway = createLocalhostGatewayController({
109
+ gateEnabled: localhostGatewayGateEnabled,
110
+ collaborationEnabled,
111
+ tokenBindingGateEnabled,
112
+ policyAuditGateEnabled,
113
+ policyMode,
114
+ controlPlane: this.controlPlane,
115
+ stateRootDir: config.stateRootDir,
116
+ resolveStableAgentId: deps.resolveStableAgentId,
117
+ logger: deps.logger,
118
+ auditSink: this.authorizationAuditSink,
119
+ authorizeCollaborationSend: deps.authorizeCollaborationSend,
120
+ readCollaborationDraft: deps.readCollaborationDraft,
121
+ });
122
+ this.provider =
123
+ deps.provider ??
124
+ createProvider({
125
+ provider: config.agent.provider,
126
+ stateRootDir: config.stateRootDir,
127
+ resolveStableAgentId: deps.resolveStableAgentId,
128
+ claudeCommand: config.claudeCommand,
129
+ claudeArgs: config.claudeArgs,
130
+ codexCommand: config.codexCommand,
131
+ codexArgs: config.codexArgs,
132
+ copilotCommand: config.copilotCommand,
133
+ copilotArgs: config.copilotArgs,
134
+ copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
135
+ }, deps.logger, {
136
+ compatibilityGates,
137
+ authorizationAuditSink: this.authorizationAuditSink,
138
+ localhostGateway: localhostGatewayGateEnabled ? this.gateway.contextPublisher : undefined,
139
+ });
140
+ this.ensureStateRoot = deps.ensureStateRoot ?? ensurePrivateStateRoot;
141
+ }
142
+ async start(onMessage) {
143
+ await this.ensureStateRoot(this.config.stateRootDir);
144
+ await this.gateway.start();
145
+ try {
146
+ await this.controlPlane.connect(onMessage);
147
+ }
148
+ catch (error) {
149
+ await this.gateway.stop().catch(() => { });
150
+ throw error;
151
+ }
152
+ }
153
+ async stop() {
154
+ let thrown;
155
+ try {
156
+ await this.controlPlane.close();
157
+ }
158
+ catch (error) {
159
+ thrown = error;
160
+ }
161
+ try {
162
+ await this.gateway.stop();
163
+ }
164
+ catch (error) {
165
+ thrown ??= error;
166
+ }
167
+ try {
168
+ await this.provider.dispose?.();
169
+ }
170
+ catch (error) {
171
+ thrown ??= error;
172
+ }
173
+ try {
174
+ await this.authorizationAuditSink?.drain();
175
+ }
176
+ catch (error) {
177
+ this.logger.error('authorization audit drain failed during shutdown', {
178
+ error: summarizeError(error),
179
+ });
180
+ }
181
+ if (thrown) {
182
+ throw thrown;
183
+ }
184
+ }
185
+ }
186
+ function createDefaultHostRuntime(config, deps) {
187
+ return new DefaultHostRuntime(config, deps);
188
+ }
11
189
  function hasVisibleText(value) {
12
190
  return value.trim().length > 0;
13
191
  }
192
+ function extractDispatchMentionIds(content) {
193
+ return [
194
+ ...content.matchAll(/(?:^|[^A-Za-z0-9_])@([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|[0-9A-HJKMNP-TV-Z]{26})/gu),
195
+ ]
196
+ .map((match) => match[1]?.trim())
197
+ .filter((value) => Boolean(value));
198
+ }
199
+ function hasCanonicalBodyMention(content, participantId) {
200
+ return extractCanonicalMentionIds(content).includes(participantId);
201
+ }
202
+ function hasRelayBodyMention(content, participantId) {
203
+ return (hasCanonicalBodyMention(content, participantId) ||
204
+ extractDispatchMentionIds(content).includes(participantId));
205
+ }
206
+ function encodeStatePathSegment(value) {
207
+ const encoded = Buffer.from(value, 'utf8').toString('hex');
208
+ return encoded.length > 0 ? encoded : 'empty';
209
+ }
210
+ function decodeStatePathSegment(value) {
211
+ if (value === 'empty') {
212
+ return '';
213
+ }
214
+ if (!/^(?:[0-9a-f]{2})+$/u.test(value)) {
215
+ return null;
216
+ }
217
+ return Buffer.from(value, 'hex').toString('utf8');
218
+ }
219
+ function summarizeAnchorContent(content) {
220
+ const collapsed = content.replace(/\s+/gu, ' ').trim();
221
+ if (collapsed.length <= 160) {
222
+ return collapsed;
223
+ }
224
+ return `${collapsed.slice(0, 157)}...`;
225
+ }
226
+ function nextProtocolFallbackDeadline(now = Date.now()) {
227
+ return now + PROTOCOL_PROVIDER_TURN_TIMEOUT_MS;
228
+ }
229
+ function nextProtocolKickoffDecisionDeadline(now = Date.now()) {
230
+ return now + PROTOCOL_KICKOFF_DECISION_TIMEOUT_MS;
231
+ }
232
+ function delay(ms) {
233
+ return new Promise((resolveDelay) => {
234
+ setTimeout(resolveDelay, ms);
235
+ });
236
+ }
237
+ function isReplyTriggeringEventType(eventType) {
238
+ if (eventType == null) {
239
+ return true;
240
+ }
241
+ const normalized = eventType.trim();
242
+ // The SDK forwards lowercase inbound kind literals into ChannelMessageEvent.type.
243
+ return normalized === '' || normalized === 'message' || normalized === 'mention';
244
+ }
245
+ function resolvePublicReplyText(reply) {
246
+ if (reply.controlMalformed) {
247
+ return '';
248
+ }
249
+ if (hasVisibleText(reply.text)) {
250
+ return reply.text;
251
+ }
252
+ return reply.awaitingUser?.question ?? '';
253
+ }
14
254
  function providerUsesDraftProgress(provider) {
15
- return provider === 'claude' || provider === 'copilot';
255
+ return provider === 'claude' || provider === 'codex' || provider === 'copilot';
16
256
  }
17
257
  class DraftMessageController {
18
258
  borgee;
19
259
  channelId;
20
260
  canWrite;
261
+ logger;
262
+ creationMetadata;
21
263
  messageId = null;
22
264
  currentBody = '';
23
265
  pendingBody = null;
24
266
  timer = null;
25
267
  sealed = false;
26
268
  writeChain = Promise.resolve();
27
- constructor(borgee, channelId, canWrite) {
269
+ constructor(borgee, channelId, canWrite, logger, creationMetadata) {
28
270
  this.borgee = borgee;
29
271
  this.channelId = channelId;
30
272
  this.canWrite = canWrite;
273
+ this.logger = logger;
274
+ this.creationMetadata = creationMetadata;
31
275
  }
32
276
  update(text) {
33
277
  if (this.sealed || !hasVisibleText(text)) {
@@ -40,7 +284,7 @@ class DraftMessageController {
40
284
  this.timer = setTimeout(() => {
41
285
  this.timer = null;
42
286
  void this.flushPending().catch((error) => {
43
- console.error('[agents-host] failed to flush draft progress:', error);
287
+ this.logger.error('failed to flush draft progress', error);
44
288
  });
45
289
  }, STREAM_PROGRESS_EDIT_THROTTLE_MS);
46
290
  }
@@ -60,7 +304,7 @@ class DraftMessageController {
60
304
  if (hasVisibleText(finalText)) {
61
305
  this.pendingBody = finalText;
62
306
  await this.flushPending();
63
- return;
307
+ return this.messageId;
64
308
  }
65
309
  await this.enqueueWrite(async () => {
66
310
  if (!this.canWrite() || !this.messageId) {
@@ -71,6 +315,7 @@ class DraftMessageController {
71
315
  this.currentBody = '';
72
316
  this.pendingBody = null;
73
317
  });
318
+ return null;
74
319
  }
75
320
  async discard() {
76
321
  this.sealed = true;
@@ -80,7 +325,7 @@ class DraftMessageController {
80
325
  }
81
326
  this.pendingBody = null;
82
327
  await this.enqueueWrite(async () => {
83
- if (!this.canWrite() || !this.messageId) {
328
+ if (!this.messageId) {
84
329
  return;
85
330
  }
86
331
  await this.borgee.deleteMessage(this.messageId);
@@ -98,7 +343,11 @@ class DraftMessageController {
98
343
  await this.borgee.editMessage(this.messageId, body);
99
344
  }
100
345
  else {
101
- const posted = await this.borgee.postMessage(this.channelId, body);
346
+ const posted = await this.borgee.postMessage({
347
+ channelId: this.channelId,
348
+ body,
349
+ replyToId: this.creationMetadata?.replyToId,
350
+ });
102
351
  this.messageId = posted.messageId;
103
352
  }
104
353
  this.currentBody = body;
@@ -109,201 +358,1910 @@ class DraftMessageController {
109
358
  return this.writeChain;
110
359
  }
111
360
  }
361
+ const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
362
+ const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
363
+ const COLLABORATION_SENDS_PER_TURN = 1;
112
364
  /**
113
365
  * Minimal single-agent agents host: connects one local Claude/Copilot CLI
114
366
  * to exactly one Borgee agent over `@borgee/plugin-sdk` (BPP / `/ws/plugin`).
115
- *
116
- * Conversation memory is handled entirely by each provider's native
117
- * per-channel CLI session (Claude `--resume`, Copilot `--session-id`) — see
118
- * the cli-client.ts file under each provider's folder. This class does not
119
- * keep any message history itself.
120
- *
121
- * Out of scope for this single-agent runner (see README): execution/remote-command
122
- * dispatch, node provisioning, systemd install, and scheduled (periodic)
123
- * prompts. Multi-agent local hosting is handled one level up by the local-config
124
- * supervisor, which creates one isolated `AgentsHost` per effective agent key.
125
- *
126
- * Message gating (mention-only / DM rules) is NOT done here: the server
127
- * enforces per-agent + per-channel `require_mention` at BPP fan-out, so this
128
- * host simply replies to every message it is handed (minus its own).
129
367
  */
130
368
  export class AgentsHost {
131
369
  config;
370
+ runtime;
132
371
  provider;
133
372
  borgee;
373
+ logger;
374
+ collaborationEnabled;
134
375
  activeTurns = new Set();
376
+ awaitingUserByChannel = new Map();
135
377
  progressChannelQueues = new Map();
136
378
  progressDrafts = new Map();
379
+ collaborationDrafts = new Map();
380
+ collaborationChannels = new Map();
381
+ participantDirectoryByUserId = new Map();
382
+ participantKindsByUserId = new Map();
383
+ participantRefreshBackoffByUserId = new Map();
384
+ participantRefreshPromise = null;
385
+ participantDirectorySupported = true;
137
386
  selfAgentId = null;
387
+ selfAgentProfile = null;
138
388
  selfAgentIdPromise = null;
139
389
  started = false;
140
390
  controlPlaneClosed = false;
141
391
  constructor(config, deps) {
142
392
  this.config = config;
143
- this.provider = deps?.provider ?? createProvider({
144
- provider: config.agent.provider,
145
- stateRootDir: config.stateRootDir,
393
+ this.logger = new HostLogger(deps?.runtimeOptions);
394
+ const compatibilityGates = resolveInternalCompatibilityGates();
395
+ const contextInjectionGateEnabled = compatibilityGates.has(CONTEXT_INJECTION_COMPATIBILITY_GATE);
396
+ const skillRuntimeGateEnabled = compatibilityGates.has(SKILL_RUNTIME_COMPATIBILITY_GATE);
397
+ const localhostGatewayGateEnabled = contextInjectionGateEnabled &&
398
+ skillRuntimeGateEnabled &&
399
+ compatibilityGates.has(LOCALHOST_GATEWAY_COMPATIBILITY_GATE);
400
+ const policyAuditGateEnabled = compatibilityGates.has(POLICY_AUDIT_ENFORCEMENT_COMPATIBILITY_GATE);
401
+ this.collaborationEnabled =
402
+ localhostGatewayGateEnabled &&
403
+ compatibilityGates.has(COLLABORATION_SKILL_FIRST_COMPATIBILITY_GATE) &&
404
+ policyAuditGateEnabled &&
405
+ resolveInternalPolicyMode(policyAuditGateEnabled) === 'enforce';
406
+ this.runtime = createDefaultHostRuntime(config, {
407
+ logger: this.logger,
408
+ provider: deps?.provider,
409
+ borgee: deps?.borgee,
146
410
  resolveStableAgentId: () => this.selfAgentId ?? undefined,
147
- claudeCommand: config.claudeCommand,
148
- claudeArgs: config.claudeArgs,
149
- copilotCommand: config.copilotCommand,
150
- copilotArgs: config.copilotArgs,
151
- copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
152
- });
153
- this.borgee = deps?.borgee ?? new SdkChatControlPlane(config.borgeeBaseUrl, config.agent.agentApiKey, undefined, {
154
- cursorStore: createDurableCursorStore({
155
- stateRootDir: config.stateRootDir,
156
- }),
157
- // Lets the server know which runtime/provider actually connected
158
- // (internal/bpp ConnectHandler → users.last_connected_plugin_id), so
159
- // the web UI can show real state instead of a client-side guess.
160
- pluginId: `agents-host:${config.agent.provider}`,
411
+ readCollaborationDraft: (input) => this.readCollaborationDraft(input),
412
+ authorizeCollaborationSend: (input) => this.authorizeCollaborationSend(input),
161
413
  });
414
+ this.provider = this.runtime.provider;
415
+ this.borgee = this.runtime.controlPlane;
162
416
  }
163
417
  async start() {
164
418
  if (this.started)
165
419
  return;
166
420
  this.started = true;
167
421
  this.controlPlaneClosed = false;
168
- await ensurePrivateStateRoot(this.config.stateRootDir);
169
- await this.borgee.connect((message) => {
170
- if (!this.started) {
171
- return;
172
- }
173
- const task = providerUsesDraftProgress(this.config.agent.provider)
174
- ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleMessage(message))
175
- : this.handleMessage(message);
176
- this.trackActiveTurn(task);
177
- });
178
- const agentId = await this.ensureSelfAgentId();
179
- console.log('[agents-host] connected', {
180
- agentId,
181
- agentName: this.config.agent.agentName,
422
+ this.logger.debug('starting host', {
182
423
  provider: this.config.agent.provider,
424
+ agentName: this.config.agent.agentName,
425
+ stateRootDir: this.config.stateRootDir,
426
+ collaborationEnabled: this.collaborationEnabled,
183
427
  });
428
+ const bufferedMessages = [];
429
+ let bufferingMessages = true;
430
+ try {
431
+ await this.runtime.start((message) => {
432
+ if (!this.started) {
433
+ return;
434
+ }
435
+ if (bufferingMessages) {
436
+ bufferedMessages.push(message);
437
+ return;
438
+ }
439
+ this.dispatchMessage(message);
440
+ });
441
+ const agentId = await this.ensureSelfAgentId();
442
+ bufferingMessages = false;
443
+ while (bufferedMessages.length > 0) {
444
+ const pendingMessages = bufferedMessages.splice(0, bufferedMessages.length);
445
+ for (const message of pendingMessages) {
446
+ this.dispatchMessage(message);
447
+ }
448
+ }
449
+ this.logger.debug('connected host', {
450
+ agentId,
451
+ agentName: this.config.agent.agentName,
452
+ provider: this.config.agent.provider,
453
+ });
454
+ }
455
+ catch (error) {
456
+ bufferingMessages = false;
457
+ bufferedMessages.length = 0;
458
+ this.started = false;
459
+ this.controlPlaneClosed = true;
460
+ await this.runtime.stop().catch(() => { });
461
+ throw error;
462
+ }
184
463
  }
185
464
  async stop() {
186
465
  if (!this.started)
187
466
  return;
188
467
  this.started = false;
468
+ this.logger.debug('stopping host', {
469
+ activeTurnCount: this.activeTurns.size,
470
+ provider: this.config.agent.provider,
471
+ agentName: this.config.agent.agentName,
472
+ });
473
+ for (const channelId of this.collaborationChannels.keys()) {
474
+ this.invalidateChannelTurn(channelId);
475
+ this.clearProtocolFallback(this.collaborationChannels.get(channelId));
476
+ }
189
477
  for (const draft of this.progressDrafts.values()) {
190
478
  draft.cancelProgress();
191
479
  }
480
+ for (const channelId of [...this.collaborationDrafts.keys()]) {
481
+ this.clearCollaborationDraft(channelId);
482
+ }
483
+ this.awaitingUserByChannel.clear();
192
484
  this.controlPlaneClosed = true;
193
- await this.borgee.close();
194
- await this.provider.dispose?.();
485
+ await this.runtime.stop();
195
486
  await Promise.allSettled([...this.activeTurns]);
196
487
  }
197
- async handleMessage(msg) {
198
- console.log('[agents-host] received message', {
199
- eventType: msg.type,
200
- provider: this.config.agent.provider,
201
- agentName: this.config.agent.agentName,
202
- channelId: msg.channel_id,
488
+ trackActiveTurn(task) {
489
+ this.activeTurns.add(task);
490
+ void task.finally(() => {
491
+ this.activeTurns.delete(task);
203
492
  });
204
- const selfAgentId = await this.ensureSelfAgentId();
205
- const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
206
- if (authorId === selfAgentId) {
493
+ }
494
+ dispatchMessage(message) {
495
+ const task = this.collaborationEnabled
496
+ ? this.handleCollaborationMessage(message)
497
+ : providerUsesDraftProgress(this.config.agent.provider)
498
+ ? this.enqueueProgressChannelTurn(message.channel_id, () => this.handleLegacyMessage(message))
499
+ : this.handleLegacyMessage(message);
500
+ this.trackActiveTurn(task);
501
+ }
502
+ async handleLegacyMessage(msg) {
503
+ const normalized = await this.normalizeMessage(msg);
504
+ if (!normalized) {
207
505
  return;
208
506
  }
209
- const content = String(msg.content ?? msg.body ?? '').trim();
210
- if (!content)
211
- return;
212
- // No client-side mention/DM gating here: the server already decides which
213
- // messages an agent receives (per-agent + per-channel require_mention is
214
- // enforced at BPP fan-out via AgentReceivesChannelMessage). If a channel
215
- // message reaches this host at all, the agent is meant to handle it.
216
- const stopTyping = this.borgee.startTyping(msg.channel_id);
217
- const useDraftProgress = providerUsesDraftProgress(this.config.agent.provider);
218
- try {
219
- const reply = await this.provider.generateReply({
220
- agentName: this.config.agent.agentName,
221
- provider: this.config.agent.provider,
222
- channelId: msg.channel_id,
223
- incomingAuthorId: authorId,
224
- incomingContent: content,
225
- }, useDraftProgress
226
- ? {
227
- onProgress: (update) => {
228
- if (!this.started || this.controlPlaneClosed) {
229
- return;
230
- }
231
- this.ensureProgressDraft(msg.channel_id).update(update.text);
232
- },
507
+ if (msg.message_type === 'task_assignment') {
508
+ await this.markAssignedTaskInProgress(msg.channel_id, normalized.content);
509
+ }
510
+ await this.runUngatedTurn(msg, normalized.authorId, normalized.content);
511
+ }
512
+ async handleCollaborationMessage(msg) {
513
+ const normalized = await this.normalizeMessage(msg);
514
+ if (!normalized) {
515
+ return;
516
+ }
517
+ if (msg.message_type === 'task_assignment') {
518
+ await this.markAssignedTaskInProgress(msg.channel_id, normalized.content);
519
+ }
520
+ const channelState = this.ensureCollaborationChannelState(msg.channel_id);
521
+ await this.ensureProtocolStateLoaded(msg.channel_id, channelState);
522
+ const selfAgentId = await this.ensureSelfAgentId();
523
+ const authorKind = await this.classifyAuthor(normalized.authorId, false);
524
+ if (channelState.protocol?.active) {
525
+ if (msg.message_id && authorKind === 'agent') {
526
+ await this.handleActiveProtocolParticipantMessage(msg, normalized, channelState, channelState.protocol, selfAgentId);
527
+ if (channelState.protocol?.active ||
528
+ channelState.queue.some((message) => message.kind === 'protocol-turn')) {
529
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
530
+ return;
233
531
  }
234
- : undefined);
235
- if (useDraftProgress) {
236
- const draft = this.progressDrafts.get(msg.channel_id);
237
- if (!this.started) {
238
- await draft?.discard().catch(() => { });
239
- this.progressDrafts.delete(msg.channel_id);
532
+ }
533
+ if (authorKind !== 'agent') {
534
+ if (msg.message_id === channelState.protocol.anchorMessageId) {
535
+ await this.handleActiveProtocolAnchorReplay(msg, normalized, channelState, channelState.protocol, selfAgentId);
240
536
  return;
241
537
  }
242
- await (draft ?? this.ensureProgressDraft(msg.channel_id)).finalize(reply.text);
243
- this.progressDrafts.delete(msg.channel_id);
538
+ this.cancelProtocolState(msg.channel_id, channelState);
539
+ }
540
+ }
541
+ if (channelState.blocked) {
542
+ const blockedAuthorKind = await this.classifyBlockedWakeAuthor(normalized.authorId, channelState.blocked);
543
+ if (blockedAuthorKind === 'agent') {
544
+ channelState.blocked.suppressedAgentMessages += 1;
545
+ return;
244
546
  }
245
- else if (this.started && !this.controlPlaneClosed) {
246
- await this.borgee.postMessage(msg.channel_id, reply.text);
547
+ if (blockedAuthorKind === 'unknown') {
548
+ this.enqueueCollaborationMessage(channelState, {
549
+ raw: msg,
550
+ authorId: normalized.authorId,
551
+ authorKind: 'unknown',
552
+ content: normalized.content,
553
+ });
554
+ const resumed = await this.tryResumeBlockedFromQueue(msg.channel_id, channelState);
555
+ if (resumed) {
556
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
557
+ }
558
+ return;
247
559
  }
560
+ const wakeBlockedState = channelState.blocked.awaitingUser;
561
+ channelState.blocked = undefined;
562
+ this.awaitingUserByChannel.delete(msg.channel_id);
563
+ channelState.queue = [
564
+ {
565
+ raw: msg,
566
+ authorId: normalized.authorId,
567
+ authorKind: 'human',
568
+ content: normalized.content,
569
+ wakeBlockedState,
570
+ },
571
+ ];
572
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
573
+ return;
248
574
  }
249
- catch (error) {
250
- if (useDraftProgress) {
251
- const draft = this.progressDrafts.get(msg.channel_id);
252
- if (draft) {
253
- await draft.discard().catch(() => { });
254
- this.progressDrafts.delete(msg.channel_id);
575
+ if (authorKind !== 'agent') {
576
+ const protocolCandidate = this.detectProtocolKickoffCandidate(msg.message_id, normalized.content, selfAgentId);
577
+ if (protocolCandidate) {
578
+ const protocolRequest = await this.resolveProtocolKickoffRequest(msg, normalized, protocolCandidate, selfAgentId);
579
+ if (protocolRequest) {
580
+ await this.startProtocolForAnchor(msg, normalized, channelState, protocolRequest, selfAgentId);
581
+ return;
255
582
  }
256
583
  }
257
- console.error('[agents-host] failed to generate or send reply:', {
258
- provider: this.config.agent.provider,
259
- agentName: this.config.agent.agentName,
260
- error,
261
- });
262
584
  }
263
- finally {
264
- stopTyping();
585
+ if (authorKind !== 'agent' && channelState.activeTurn) {
586
+ this.supersedeActiveTurn(msg.channel_id, channelState.activeTurn);
265
587
  }
266
- }
267
- trackActiveTurn(task) {
268
- this.activeTurns.add(task);
269
- void task.finally(() => {
270
- this.activeTurns.delete(task);
588
+ if (authorKind !== 'agent') {
589
+ channelState.recentTurn = undefined;
590
+ }
591
+ this.enqueueCollaborationMessage(channelState, {
592
+ raw: msg,
593
+ authorId: normalized.authorId,
594
+ authorKind,
595
+ content: normalized.content,
271
596
  });
597
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
272
598
  }
273
- enqueueProgressChannelTurn(channelId, task) {
274
- const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
275
- const next = previous
276
- .catch(() => { })
277
- .then(task)
278
- .finally(() => {
279
- if (this.progressChannelQueues.get(channelId) === next) {
280
- this.progressChannelQueues.delete(channelId);
599
+ detectProtocolKickoffCandidate(anchorMessageId, content, selfAgentId) {
600
+ if (!anchorMessageId) {
601
+ return null;
602
+ }
603
+ const mentions = extractCanonicalMentionIds(content);
604
+ if (mentions.length !== 2) {
605
+ return null;
606
+ }
607
+ const unique = [...new Set(mentions)].sort();
608
+ if (unique.length !== 2 || !unique.includes(selfAgentId)) {
609
+ return null;
610
+ }
611
+ return {
612
+ anchorMessageId,
613
+ participantIds: [unique[0], unique[1]],
614
+ issuerId: unique[0],
615
+ };
616
+ }
617
+ async validateProtocolKickoffCandidate(anchorAuthorId, protocolCandidate, selfAgentId) {
618
+ const anchorAuthorKind = await this.classifyAuthor(anchorAuthorId, true);
619
+ if (anchorAuthorKind === 'agent') {
620
+ return null;
621
+ }
622
+ if (protocolCandidate.participantIds.includes(anchorAuthorId)) {
623
+ return null;
624
+ }
625
+ for (const participantId of protocolCandidate.participantIds) {
626
+ if (participantId === selfAgentId) {
627
+ continue;
281
628
  }
629
+ const participantKind = await this.classifyAuthor(participantId, true);
630
+ if (participantKind === 'human') {
631
+ return null;
632
+ }
633
+ }
634
+ return protocolCandidate;
635
+ }
636
+ async resolveProtocolKickoffRequest(msg, normalized, protocolCandidate, selfAgentId) {
637
+ const validatedCandidate = await this.validateProtocolKickoffCandidate(normalized.authorId, protocolCandidate, selfAgentId);
638
+ if (!validatedCandidate) {
639
+ return null;
640
+ }
641
+ const pendingDecision = await this.ensureProtocolKickoffDecisionRecord(msg.channel_id, validatedCandidate);
642
+ if (!pendingDecision) {
643
+ return null;
644
+ }
645
+ const decision = pendingDecision.status === 'pending'
646
+ ? validatedCandidate.issuerId === selfAgentId
647
+ ? await this.evaluateIssuerProtocolKickoff(msg, normalized, validatedCandidate, pendingDecision)
648
+ : await this.waitForProtocolKickoffDecision(msg.channel_id, validatedCandidate, pendingDecision.deadlineAt)
649
+ : pendingDecision;
650
+ if (!decision || decision.status !== 'start') {
651
+ return null;
652
+ }
653
+ const rounds = this.validateProtocolKickoffRounds(decision.rounds);
654
+ if (!rounds) {
655
+ return null;
656
+ }
657
+ return {
658
+ participantIds: decision.participantIds,
659
+ issuerId: decision.issuerId,
660
+ maxTurnCount: rounds * 2,
661
+ };
662
+ }
663
+ validateProtocolKickoffRounds(rounds) {
664
+ if (!Number.isFinite(rounds) ||
665
+ typeof rounds !== 'number' ||
666
+ !Number.isInteger(rounds) ||
667
+ rounds < 1) {
668
+ return null;
669
+ }
670
+ return Math.min(rounds, PROTOCOL_KICKOFF_ROUND_LIMIT);
671
+ }
672
+ protocolRequestFromKickoffDecision(decision) {
673
+ const rounds = this.validateProtocolKickoffRounds(decision.rounds);
674
+ if (!rounds) {
675
+ return null;
676
+ }
677
+ return {
678
+ participantIds: decision.participantIds,
679
+ issuerId: decision.issuerId,
680
+ maxTurnCount: rounds * 2,
681
+ };
682
+ }
683
+ async evaluateIssuerProtocolKickoff(msg, normalized, protocolCandidate, pendingDecision) {
684
+ const executed = await this.executeTurn(msg, normalized.authorId, normalized.content, {
685
+ kickoff: protocolCandidate,
686
+ silent: true,
282
687
  });
283
- this.progressChannelQueues.set(channelId, next);
284
- return next;
688
+ if (executed.stale) {
689
+ return null;
690
+ }
691
+ const reply = executed.reply;
692
+ const rounds = reply?.control?.kind === 'start-protocol'
693
+ ? this.validateProtocolKickoffRounds(reply.control.rounds)
694
+ : null;
695
+ const finalizedDecision = await this.finalizeProtocolKickoffDecision(msg.channel_id, protocolCandidate, pendingDecision.deadlineAt, {
696
+ status: rounds ? 'start' : 'decline',
697
+ ...(rounds ? { rounds } : {}),
698
+ });
699
+ this.logger.debug('completed silent kickoff evaluation', {
700
+ channelId: msg.channel_id,
701
+ messageId: msg.message_id,
702
+ anchorMessageId: protocolCandidate.anchorMessageId,
703
+ provider: this.config.agent.provider,
704
+ agentName: this.config.agent.agentName,
705
+ controlKind: reply?.control?.kind ?? null,
706
+ controlMalformed: reply?.controlMalformed ?? false,
707
+ outcome: rounds ? 'start' : reply?.controlMalformed ? 'malformed-control' : 'no-control',
708
+ persistedStatus: finalizedDecision?.status ?? null,
709
+ persistedRounds: finalizedDecision?.rounds ?? null,
710
+ replyLength: reply ? resolvePublicReplyText(reply).length : 0,
711
+ });
712
+ return finalizedDecision;
285
713
  }
286
- ensureProgressDraft(channelId) {
287
- let draft = this.progressDrafts.get(channelId);
288
- if (!draft) {
289
- draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed);
290
- this.progressDrafts.set(channelId, draft);
714
+ async waitForProtocolKickoffDecision(channelId, protocolCandidate, deadlineAt) {
715
+ for (;;) {
716
+ const decision = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
717
+ if (decision &&
718
+ this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, decision)) {
719
+ if (decision.status !== 'pending') {
720
+ return decision;
721
+ }
722
+ const now = Date.now();
723
+ if (decision.deadlineAt <= now) {
724
+ return null;
725
+ }
726
+ await delay(Math.min(PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS, decision.deadlineAt - now));
727
+ continue;
728
+ }
729
+ const now = Date.now();
730
+ if (deadlineAt <= now) {
731
+ return null;
732
+ }
733
+ await delay(Math.min(PROTOCOL_KICKOFF_DECISION_POLL_INTERVAL_MS, deadlineAt - now));
291
734
  }
292
- return draft;
293
735
  }
294
- async ensureSelfAgentId() {
295
- if (this.selfAgentId) {
296
- return this.selfAgentId;
736
+ async ensureProtocolKickoffDecisionRecord(channelId, protocolCandidate) {
737
+ const existing = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
738
+ if (existing) {
739
+ return this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, existing)
740
+ ? existing
741
+ : null;
297
742
  }
298
- if (!this.selfAgentIdPromise) {
299
- this.selfAgentIdPromise = this.borgee.getMe().then((me) => {
300
- this.selfAgentId = me.id;
301
- return me.id;
302
- }).catch((error) => {
303
- this.selfAgentIdPromise = null;
743
+ const record = {
744
+ channelId,
745
+ ...protocolCandidate,
746
+ deadlineAt: nextProtocolKickoffDecisionDeadline(),
747
+ status: 'pending',
748
+ };
749
+ try {
750
+ await this.writeProtocolKickoffDecisionRecord(record, 'wx');
751
+ return record;
752
+ }
753
+ catch (error) {
754
+ const code = error.code;
755
+ if (code !== 'EEXIST') {
304
756
  throw error;
757
+ }
758
+ }
759
+ const persisted = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
760
+ return persisted &&
761
+ this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, persisted)
762
+ ? persisted
763
+ : null;
764
+ }
765
+ async finalizeProtocolKickoffDecision(channelId, protocolCandidate, deadlineAt, decision) {
766
+ const current = await this.readProtocolKickoffDecisionRecord(channelId, protocolCandidate.anchorMessageId);
767
+ if (current &&
768
+ !this.matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, current)) {
769
+ return null;
770
+ }
771
+ const effectiveDeadlineAt = current?.deadlineAt ?? deadlineAt;
772
+ if (effectiveDeadlineAt <= Date.now()) {
773
+ if (!current || current.status === 'pending') {
774
+ await this.writeProtocolKickoffDecisionRecord({
775
+ channelId,
776
+ ...protocolCandidate,
777
+ deadlineAt: effectiveDeadlineAt,
778
+ status: 'decline',
779
+ decidedAt: Date.now(),
780
+ });
781
+ }
782
+ return null;
783
+ }
784
+ if (current?.status === 'start') {
785
+ return current;
786
+ }
787
+ if (current?.status === 'decline') {
788
+ return current;
789
+ }
790
+ const finalized = {
791
+ channelId,
792
+ ...protocolCandidate,
793
+ deadlineAt: effectiveDeadlineAt,
794
+ status: decision.status,
795
+ ...(decision.rounds ? { rounds: decision.rounds } : {}),
796
+ decidedAt: Date.now(),
797
+ };
798
+ await this.writeProtocolKickoffDecisionRecord(finalized);
799
+ return finalized;
800
+ }
801
+ matchesProtocolKickoffDecisionRecord(channelId, protocolCandidate, record) {
802
+ return (record.channelId === channelId &&
803
+ record.anchorMessageId === protocolCandidate.anchorMessageId &&
804
+ record.issuerId === protocolCandidate.issuerId &&
805
+ record.participantIds[0] === protocolCandidate.participantIds[0] &&
806
+ record.participantIds[1] === protocolCandidate.participantIds[1]);
807
+ }
808
+ resolveProtocolKickoffDecisionPath(channelId, anchorMessageId) {
809
+ return resolveSharedProtocolKickoffDecisionPath(this.config.stateRootDir, channelId, anchorMessageId);
810
+ }
811
+ resolveSharedProtocolStatusPath(channelId, anchorMessageId) {
812
+ return resolveSharedProtocolStatusPath(this.config.stateRootDir, channelId, anchorMessageId);
813
+ }
814
+ async readProtocolKickoffDecisionRecord(channelId, anchorMessageId) {
815
+ try {
816
+ const content = await readFile(this.resolveProtocolKickoffDecisionPath(channelId, anchorMessageId), 'utf8');
817
+ return JSON.parse(content);
818
+ }
819
+ catch (error) {
820
+ const code = error.code;
821
+ if (code === 'ENOENT') {
822
+ return null;
823
+ }
824
+ throw error;
825
+ }
826
+ }
827
+ async writeProtocolKickoffDecisionRecord(record, flag = 'w') {
828
+ const path = this.resolveProtocolKickoffDecisionPath(record.channelId, record.anchorMessageId);
829
+ await writePrivateJsonFile(path, record, { exclusive: flag === 'wx' });
830
+ }
831
+ async handleActiveProtocolAnchorReplay(msg, normalized, channelState, protocolState, selfAgentId) {
832
+ if (protocolState.currentTurnIndex > 0) {
833
+ return;
834
+ }
835
+ if (selfAgentId === protocolState.issuerId) {
836
+ this.enqueueCollaborationMessage(channelState, {
837
+ kind: 'protocol-turn',
838
+ raw: msg,
839
+ authorId: normalized.authorId,
840
+ authorKind: 'human',
841
+ content: normalized.content,
842
+ });
843
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
844
+ return;
845
+ }
846
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
847
+ }
848
+ async startProtocolForAnchor(msg, normalized, channelState, protocolRequest, selfAgentId) {
849
+ this.cancelProtocolState(msg.channel_id, channelState);
850
+ const protocolState = {
851
+ ownerAgentId: selfAgentId,
852
+ anchorAuthorId: normalized.authorId,
853
+ anchorContent: normalized.content,
854
+ anchorMessageId: msg.message_id ?? randomUUID(),
855
+ participantIds: protocolRequest.participantIds,
856
+ issuerId: protocolRequest.issuerId,
857
+ expectedNextSpeakerId: protocolRequest.issuerId,
858
+ currentTurnIndex: 0,
859
+ maxTurnCount: protocolRequest.maxTurnCount,
860
+ active: true,
861
+ finished: false,
862
+ fallbackDeadlineAt: nextProtocolFallbackDeadline(),
863
+ fallbackTimer: null,
864
+ };
865
+ channelState.protocol = protocolState;
866
+ channelState.protocolLoaded = true;
867
+ await this.persistProtocolState(msg.channel_id, protocolState);
868
+ await this.persistSharedProtocolStatus(msg.channel_id, protocolState);
869
+ if (selfAgentId === protocolRequest.issuerId) {
870
+ this.enqueueCollaborationMessage(channelState, {
871
+ kind: 'protocol-turn',
872
+ raw: msg,
873
+ authorId: normalized.authorId,
874
+ authorKind: 'human',
875
+ content: normalized.content,
305
876
  });
877
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
878
+ return;
306
879
  }
307
- return this.selfAgentIdPromise;
880
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
881
+ }
882
+ async deliverDeferredTurnReply(channelId, activeTurn, executed) {
883
+ if (!executed.completed ||
884
+ executed.stale ||
885
+ !this.canAcceptTurnOutput(channelId, activeTurn.generation)) {
886
+ return;
887
+ }
888
+ const publicReplyText = executed.publicReplyText ?? '';
889
+ if (!this.started || this.controlPlaneClosed || !hasVisibleText(publicReplyText)) {
890
+ return;
891
+ }
892
+ const posted = await this.borgee.postMessage({
893
+ channelId,
894
+ body: executed.visibleReplyBody ?? publicReplyText,
895
+ replyToId: activeTurn.delivery?.replyToId,
896
+ });
897
+ executed.deliveredMessageId = posted.messageId;
898
+ }
899
+ async handleActiveProtocolParticipantMessage(msg, normalized, channelState, protocolState, selfAgentId) {
900
+ if (!msg.message_id || normalized.authorId !== protocolState.expectedNextSpeakerId) {
901
+ return;
902
+ }
903
+ const expectedReplyToId = protocolState.lastProtocolMessageId ?? protocolState.anchorMessageId;
904
+ const confirmed = await this.confirmProtocolMessage(msg.channel_id, msg.message_id, normalized.authorId, expectedReplyToId, msg.created_at);
905
+ if (!confirmed) {
906
+ if (selfAgentId !== protocolState.issuerId) {
907
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
908
+ }
909
+ return;
910
+ }
911
+ this.clearProtocolFallback(channelState);
912
+ protocolState.currentTurnIndex += 1;
913
+ protocolState.lastProtocolMessageId = msg.message_id;
914
+ const continuesProtocol = msg.type === 'mention' || hasRelayBodyMention(confirmed.body, selfAgentId);
915
+ if (!continuesProtocol) {
916
+ protocolState.active = false;
917
+ protocolState.finished = true;
918
+ protocolState.expectedNextSpeakerId = normalized.authorId;
919
+ await this.persistProtocolState(msg.channel_id, protocolState);
920
+ return;
921
+ }
922
+ if (protocolState.currentTurnIndex >= protocolState.maxTurnCount) {
923
+ protocolState.active = false;
924
+ protocolState.finished = true;
925
+ protocolState.expectedNextSpeakerId = normalized.authorId;
926
+ await this.persistProtocolState(msg.channel_id, protocolState);
927
+ return;
928
+ }
929
+ protocolState.expectedNextSpeakerId = this.otherProtocolParticipant(protocolState, normalized.authorId);
930
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
931
+ await this.persistProtocolState(msg.channel_id, protocolState);
932
+ if (protocolState.expectedNextSpeakerId !== selfAgentId) {
933
+ if (selfAgentId !== protocolState.issuerId) {
934
+ this.armProtocolFallback(msg.channel_id, channelState, protocolState);
935
+ }
936
+ return;
937
+ }
938
+ this.enqueueCollaborationMessage(channelState, {
939
+ kind: 'protocol-turn',
940
+ raw: msg,
941
+ authorId: normalized.authorId,
942
+ authorKind: 'agent',
943
+ content: normalized.content,
944
+ });
945
+ await this.ensureCollaborationProcessor(msg.channel_id, channelState);
946
+ }
947
+ enqueueCollaborationMessage(channelState, message) {
948
+ channelState.queue.push(message);
949
+ while (channelState.queue.length > MAX_QUEUED_MESSAGES_PER_CHANNEL) {
950
+ channelState.queue.shift();
951
+ }
952
+ }
953
+ ensureCollaborationChannelState(channelId) {
954
+ let state = this.collaborationChannels.get(channelId);
955
+ if (!state) {
956
+ state = {
957
+ generation: 0,
958
+ queue: [],
959
+ running: false,
960
+ protocolLoaded: false,
961
+ };
962
+ this.collaborationChannels.set(channelId, state);
963
+ }
964
+ return state;
965
+ }
966
+ async ensureCollaborationProcessor(channelId, channelState) {
967
+ if (channelState.processor) {
968
+ await channelState.processor;
969
+ return;
970
+ }
971
+ const processor = this.processCollaborationQueue(channelId, channelState).finally(() => {
972
+ if (channelState.processor === processor) {
973
+ channelState.processor = undefined;
974
+ }
975
+ });
976
+ channelState.processor = processor;
977
+ await processor;
978
+ }
979
+ async processCollaborationQueue(channelId, channelState) {
980
+ while (this.started && !this.controlPlaneClosed && !channelState.blocked) {
981
+ const next = channelState.queue.shift();
982
+ if (!next) {
983
+ return;
984
+ }
985
+ channelState.running = true;
986
+ try {
987
+ await this.runCollaborationTurn(channelId, channelState, next);
988
+ }
989
+ finally {
990
+ channelState.running = false;
991
+ }
992
+ if (channelState.blocked) {
993
+ channelState.queue = channelState.queue.filter((queued) => queued.authorKind !== 'agent');
994
+ const resumed = await this.tryResumeBlockedFromQueue(channelId, channelState);
995
+ if (resumed) {
996
+ continue;
997
+ }
998
+ }
999
+ }
1000
+ }
1001
+ async tryResumeBlockedFromQueue(channelId, channelState) {
1002
+ const blocked = channelState.blocked;
1003
+ if (!blocked || channelState.queue.length === 0) {
1004
+ return false;
1005
+ }
1006
+ const retained = [];
1007
+ for (const [index, queued] of channelState.queue.entries()) {
1008
+ const classified = queued.authorKind === 'human'
1009
+ ? 'human'
1010
+ : await this.classifyQueuedBlockedWakeCandidate(queued, blocked);
1011
+ if (classified === 'agent') {
1012
+ blocked.suppressedAgentMessages += 1;
1013
+ continue;
1014
+ }
1015
+ if (classified === 'unknown') {
1016
+ retained.push(queued);
1017
+ continue;
1018
+ }
1019
+ const wakeBlockedState = blocked.awaitingUser;
1020
+ channelState.blocked = undefined;
1021
+ this.awaitingUserByChannel.delete(channelId);
1022
+ channelState.queue = [
1023
+ {
1024
+ ...queued,
1025
+ authorKind: 'human',
1026
+ wakeBlockedState,
1027
+ },
1028
+ ...channelState.queue.slice(index + 1),
1029
+ ];
1030
+ return true;
1031
+ }
1032
+ channelState.queue = retained;
1033
+ return false;
1034
+ }
1035
+ async runCollaborationTurn(channelId, channelState, message) {
1036
+ if (message.kind === 'protocol-turn') {
1037
+ await this.runProtocolManagedTurn(channelId, channelState, message);
1038
+ return;
1039
+ }
1040
+ channelState.generation += 1;
1041
+ const activeTurn = {
1042
+ generation: channelState.generation,
1043
+ turnExecutionId: randomUUID(),
1044
+ superseded: false,
1045
+ collaborationMessagesSent: 0,
1046
+ targetCooldowns: new Map(),
1047
+ };
1048
+ this.clearCollaborationDraft(channelId);
1049
+ channelState.recentTurn = undefined;
1050
+ channelState.activeTurn = activeTurn;
1051
+ const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1052
+ activeTurn,
1053
+ incomingAuthorKind: message.authorKind,
1054
+ });
1055
+ channelState.activeTurn = undefined;
1056
+ if (executed.stale) {
1057
+ return;
1058
+ }
1059
+ if (executed.completed) {
1060
+ this.rememberRecentTurn(channelState, activeTurn);
1061
+ }
1062
+ if (executed.reply?.awaitingUser) {
1063
+ channelState.blocked = {
1064
+ awaitingUser: executed.reply.awaitingUser,
1065
+ suppressedAgentMessages: 0,
1066
+ };
1067
+ this.awaitingUserByChannel.set(channelId, executed.reply.awaitingUser);
1068
+ return;
1069
+ }
1070
+ channelState.blocked = undefined;
1071
+ this.awaitingUserByChannel.delete(channelId);
1072
+ }
1073
+ async runProtocolManagedTurn(channelId, channelState, message) {
1074
+ const protocolState = channelState.protocol;
1075
+ const selfAgentId = await this.ensureSelfAgentId();
1076
+ if (!protocolState?.active || protocolState.expectedNextSpeakerId !== selfAgentId) {
1077
+ return;
1078
+ }
1079
+ if (!(await this.canContinueSharedProtocol(channelId, channelState, protocolState))) {
1080
+ return;
1081
+ }
1082
+ channelState.generation += 1;
1083
+ const currentTurnIndex = protocolState.currentTurnIndex + 1;
1084
+ const peerId = this.otherProtocolParticipant(protocolState, selfAgentId);
1085
+ const isFinalTurn = currentTurnIndex >= protocolState.maxTurnCount;
1086
+ const activeTurn = {
1087
+ generation: channelState.generation,
1088
+ turnExecutionId: randomUUID(),
1089
+ superseded: false,
1090
+ collaborationMessagesSent: 0,
1091
+ targetCooldowns: new Map(),
1092
+ delivery: {
1093
+ replyToId: protocolState.lastProtocolMessageId ?? protocolState.anchorMessageId,
1094
+ },
1095
+ protocol: {
1096
+ anchorMessageId: protocolState.anchorMessageId,
1097
+ anchorSummary: summarizeAnchorContent(protocolState.anchorContent),
1098
+ issuerId: protocolState.issuerId,
1099
+ role: protocolState.currentTurnIndex === 0 ? 'issuer' : isFinalTurn ? 'final' : 'intermediate',
1100
+ targetPeerId: peerId,
1101
+ turnIndex: currentTurnIndex,
1102
+ maxTurnCount: protocolState.maxTurnCount,
1103
+ },
1104
+ };
1105
+ this.clearCollaborationDraft(channelId);
1106
+ channelState.recentTurn = undefined;
1107
+ channelState.activeTurn = activeTurn;
1108
+ const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1109
+ activeTurn,
1110
+ incomingAuthorKind: message.authorKind,
1111
+ });
1112
+ channelState.activeTurn = undefined;
1113
+ if (executed.stale || !protocolState.active) {
1114
+ return;
1115
+ }
1116
+ if (executed.completed) {
1117
+ this.rememberRecentTurn(channelState, activeTurn);
1118
+ }
1119
+ const deliveredMessageId = executed.deliveredMessageId ?? null;
1120
+ const visibleMessageId = deliveredMessageId ?? protocolState.lastProtocolMessageId;
1121
+ if (executed.reply?.controlMalformed) {
1122
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1123
+ return;
1124
+ }
1125
+ if (!executed.reply?.control) {
1126
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1127
+ return;
1128
+ }
1129
+ if (executed.reply.control.kind === 'awaiting-user') {
1130
+ channelState.blocked = {
1131
+ awaitingUser: executed.reply.awaitingUser ?? {
1132
+ question: executed.reply.control.question,
1133
+ ...(executed.reply.control.reason ? { reason: executed.reply.control.reason } : {}),
1134
+ },
1135
+ suppressedAgentMessages: 0,
1136
+ };
1137
+ this.awaitingUserByChannel.set(channelId, channelState.blocked.awaitingUser);
1138
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1139
+ return;
1140
+ }
1141
+ if (executed.reply.control.kind === 'start-protocol') {
1142
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1143
+ return;
1144
+ }
1145
+ if (!deliveredMessageId) {
1146
+ this.finishProtocolState(channelId, channelState, protocolState);
1147
+ this.awaitingUserByChannel.delete(channelId);
1148
+ channelState.blocked = undefined;
1149
+ return;
1150
+ }
1151
+ protocolState.currentTurnIndex = currentTurnIndex;
1152
+ if (deliveredMessageId) {
1153
+ protocolState.lastProtocolMessageId = deliveredMessageId;
1154
+ }
1155
+ if (executed.reply.control.kind === 'conclude-locally') {
1156
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1157
+ this.awaitingUserByChannel.delete(channelId);
1158
+ channelState.blocked = undefined;
1159
+ return;
1160
+ }
1161
+ if (currentTurnIndex >= protocolState.maxTurnCount) {
1162
+ this.finishProtocolState(channelId, channelState, protocolState, visibleMessageId);
1163
+ this.awaitingUserByChannel.delete(channelId);
1164
+ channelState.blocked = undefined;
1165
+ return;
1166
+ }
1167
+ protocolState.expectedNextSpeakerId = peerId;
1168
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
1169
+ await this.persistProtocolState(channelId, protocolState);
1170
+ if (selfAgentId !== protocolState.issuerId) {
1171
+ this.armProtocolFallback(channelId, channelState, protocolState);
1172
+ }
1173
+ channelState.blocked = undefined;
1174
+ this.awaitingUserByChannel.delete(channelId);
1175
+ }
1176
+ buildIncomingContent(message) {
1177
+ if (!message.wakeBlockedState) {
1178
+ return message.content;
1179
+ }
1180
+ const lines = [
1181
+ '[Internal note: a previously blocked human reply has arrived.]',
1182
+ `Pending question: ${message.wakeBlockedState.question}`,
1183
+ ...(message.wakeBlockedState.reason
1184
+ ? [`Blocked reason: ${message.wakeBlockedState.reason}`]
1185
+ : []),
1186
+ `Human reply from ${message.authorId}: ${message.content}`,
1187
+ '',
1188
+ message.content,
1189
+ ];
1190
+ return lines.join('\n');
1191
+ }
1192
+ otherProtocolParticipant(protocolState, currentAgentId) {
1193
+ return protocolState.participantIds[0] === currentAgentId
1194
+ ? protocolState.participantIds[1]
1195
+ : protocolState.participantIds[0];
1196
+ }
1197
+ resolveProtocolStatePath(channelId, ownerAgentId) {
1198
+ return resolve(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME, `${encodeStatePathSegment(channelId)}--${encodeStatePathSegment(ownerAgentId)}.json`);
1199
+ }
1200
+ async ensureProtocolStateLoaded(channelId, channelState) {
1201
+ if (channelState.protocolLoaded) {
1202
+ return;
1203
+ }
1204
+ if (channelState.protocol) {
1205
+ channelState.protocolLoaded = true;
1206
+ return;
1207
+ }
1208
+ channelState.protocolLoaded = true;
1209
+ try {
1210
+ const selfAgentId = await this.ensureSelfAgentId();
1211
+ const content = await readFile(this.resolveProtocolStatePath(channelId, selfAgentId), 'utf8');
1212
+ const parsed = JSON.parse(content);
1213
+ if ((parsed.ownerAgentId && parsed.ownerAgentId !== selfAgentId) ||
1214
+ !parsed.participantIds.includes(selfAgentId)) {
1215
+ return;
1216
+ }
1217
+ const resolvedProtocolState = await this.reconcileOwnProtocolStateFromPeers(channelId, {
1218
+ ...parsed,
1219
+ ownerAgentId: parsed.ownerAgentId ?? selfAgentId,
1220
+ }, selfAgentId);
1221
+ channelState.protocol = {
1222
+ ...resolvedProtocolState,
1223
+ fallbackTimer: null,
1224
+ };
1225
+ if (resolvedProtocolState.currentTurnIndex !== parsed.currentTurnIndex ||
1226
+ resolvedProtocolState.expectedNextSpeakerId !== parsed.expectedNextSpeakerId ||
1227
+ resolvedProtocolState.active !== parsed.active ||
1228
+ resolvedProtocolState.finished !== parsed.finished ||
1229
+ resolvedProtocolState.lastProtocolMessageId !== parsed.lastProtocolMessageId) {
1230
+ await this.persistProtocolState(channelId, channelState.protocol);
1231
+ }
1232
+ if (resolvedProtocolState.active) {
1233
+ this.armProtocolFallback(channelId, channelState, channelState.protocol);
1234
+ }
1235
+ }
1236
+ catch (error) {
1237
+ const code = error.code;
1238
+ if (code !== 'ENOENT') {
1239
+ this.logger.error('failed to load protocol state', {
1240
+ channelId,
1241
+ error: summarizeError(error),
1242
+ });
1243
+ }
1244
+ }
1245
+ }
1246
+ async reconcileOwnProtocolStateFromPeers(channelId, protocolState, selfAgentId) {
1247
+ const directoryPath = join(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME);
1248
+ const channelPrefix = `${encodeStatePathSegment(channelId)}--`;
1249
+ const ownSuffix = `--${encodeStatePathSegment(selfAgentId)}.json`;
1250
+ let entries;
1251
+ try {
1252
+ entries = await readdir(directoryPath);
1253
+ }
1254
+ catch (error) {
1255
+ const code = error.code;
1256
+ if (code === 'ENOENT') {
1257
+ return protocolState;
1258
+ }
1259
+ throw error;
1260
+ }
1261
+ let resolved = protocolState;
1262
+ for (const entry of entries) {
1263
+ if (!entry.startsWith(channelPrefix) || entry.endsWith(ownSuffix)) {
1264
+ continue;
1265
+ }
1266
+ try {
1267
+ const candidate = JSON.parse(await readFile(join(directoryPath, entry), 'utf8'));
1268
+ if (!candidate.participantIds.includes(selfAgentId)) {
1269
+ continue;
1270
+ }
1271
+ if (!shouldAdoptPeerProtocolState(resolved, candidate, selfAgentId)) {
1272
+ continue;
1273
+ }
1274
+ resolved = {
1275
+ ...candidate,
1276
+ ownerAgentId: selfAgentId,
1277
+ };
1278
+ }
1279
+ catch (error) {
1280
+ const code = error.code;
1281
+ if (code === 'ENOENT') {
1282
+ continue;
1283
+ }
1284
+ throw error;
1285
+ }
1286
+ }
1287
+ return resolved;
1288
+ }
1289
+ async loadPersistedProtocolStates() {
1290
+ if (!this.collaborationEnabled) {
1291
+ return;
1292
+ }
1293
+ const selfAgentId = await this.ensureSelfAgentId();
1294
+ const ownSuffix = `--${encodeStatePathSegment(selfAgentId)}.json`;
1295
+ const directoryPath = join(this.config.stateRootDir, PROTOCOL_STATE_DIRNAME);
1296
+ let entries;
1297
+ try {
1298
+ entries = await readdir(directoryPath);
1299
+ }
1300
+ catch (error) {
1301
+ const code = error.code;
1302
+ if (code !== 'ENOENT') {
1303
+ this.logger.error('failed to enumerate protocol state directory', {
1304
+ directoryPath,
1305
+ error: summarizeError(error),
1306
+ });
1307
+ }
1308
+ return;
1309
+ }
1310
+ for (const entry of entries) {
1311
+ if (!entry.endsWith(ownSuffix)) {
1312
+ continue;
1313
+ }
1314
+ const encodedChannelId = entry.slice(0, -ownSuffix.length);
1315
+ const channelId = decodeStatePathSegment(encodedChannelId);
1316
+ if (channelId == null) {
1317
+ continue;
1318
+ }
1319
+ const channelState = this.ensureCollaborationChannelState(channelId);
1320
+ await this.ensureProtocolStateLoaded(channelId, channelState);
1321
+ }
1322
+ }
1323
+ async resumeOwnedProtocolTurns() {
1324
+ if (!this.collaborationEnabled) {
1325
+ return;
1326
+ }
1327
+ const selfAgentId = await this.ensureSelfAgentId();
1328
+ for (const [channelId, channelState] of this.collaborationChannels.entries()) {
1329
+ const protocolState = channelState.protocol;
1330
+ if (!protocolState?.active || protocolState.expectedNextSpeakerId !== selfAgentId) {
1331
+ continue;
1332
+ }
1333
+ if (channelState.activeTurn ||
1334
+ channelState.queue.some((message) => message.kind === 'protocol-turn')) {
1335
+ continue;
1336
+ }
1337
+ const resumed = await this.buildResumedProtocolMessage(channelId, protocolState);
1338
+ if (!resumed) {
1339
+ this.cancelProtocolState(channelId, channelState);
1340
+ continue;
1341
+ }
1342
+ this.enqueueCollaborationMessage(channelState, resumed);
1343
+ await this.ensureCollaborationProcessor(channelId, channelState);
1344
+ }
1345
+ }
1346
+ async buildResumedProtocolMessage(channelId, protocolState) {
1347
+ if (protocolState.currentTurnIndex === 0) {
1348
+ return {
1349
+ kind: 'protocol-turn',
1350
+ raw: {
1351
+ channel_id: channelId,
1352
+ message_id: protocolState.anchorMessageId,
1353
+ user_id: protocolState.anchorAuthorId,
1354
+ content: protocolState.anchorContent,
1355
+ body: protocolState.anchorContent,
1356
+ type: 'message',
1357
+ },
1358
+ authorId: protocolState.anchorAuthorId,
1359
+ authorKind: 'human',
1360
+ content: protocolState.anchorContent,
1361
+ };
1362
+ }
1363
+ if (!protocolState.lastProtocolMessageId) {
1364
+ return null;
1365
+ }
1366
+ let before;
1367
+ for (;;) {
1368
+ const history = await this.borgee.readChannelHistory({
1369
+ channelId,
1370
+ before,
1371
+ limit: 200,
1372
+ });
1373
+ const priorMessage = history.find((entry) => entry.id === protocolState.lastProtocolMessageId);
1374
+ if (priorMessage) {
1375
+ return {
1376
+ kind: 'protocol-turn',
1377
+ raw: {
1378
+ channel_id: channelId,
1379
+ message_id: priorMessage.id,
1380
+ user_id: priorMessage.authorId,
1381
+ content: priorMessage.body,
1382
+ body: priorMessage.body,
1383
+ type: 'message',
1384
+ created_at: priorMessage.createdAt,
1385
+ },
1386
+ authorId: priorMessage.authorId,
1387
+ authorKind: 'agent',
1388
+ content: priorMessage.body,
1389
+ };
1390
+ }
1391
+ if (history.length < 200) {
1392
+ break;
1393
+ }
1394
+ const oldestCreatedAt = history.reduce((oldest, entry) => {
1395
+ return oldest == null || entry.createdAt < oldest ? entry.createdAt : oldest;
1396
+ }, null);
1397
+ if (oldestCreatedAt == null || (before != null && oldestCreatedAt >= before)) {
1398
+ break;
1399
+ }
1400
+ before = oldestCreatedAt;
1401
+ }
1402
+ return null;
1403
+ }
1404
+ async persistProtocolState(channelId, protocolState) {
1405
+ const ownerAgentId = protocolState.ownerAgentId ?? (await this.ensureSelfAgentId());
1406
+ protocolState.ownerAgentId = ownerAgentId;
1407
+ await writePrivateJsonFile(this.resolveProtocolStatePath(channelId, ownerAgentId), {
1408
+ ownerAgentId,
1409
+ anchorAuthorId: protocolState.anchorAuthorId,
1410
+ anchorContent: protocolState.anchorContent,
1411
+ anchorMessageId: protocolState.anchorMessageId,
1412
+ participantIds: protocolState.participantIds,
1413
+ issuerId: protocolState.issuerId,
1414
+ expectedNextSpeakerId: protocolState.expectedNextSpeakerId,
1415
+ currentTurnIndex: protocolState.currentTurnIndex,
1416
+ maxTurnCount: protocolState.maxTurnCount,
1417
+ lastProtocolMessageId: protocolState.lastProtocolMessageId,
1418
+ active: protocolState.active,
1419
+ finished: protocolState.finished,
1420
+ fallbackDeadlineAt: protocolState.fallbackDeadlineAt,
1421
+ });
1422
+ }
1423
+ async persistSharedProtocolStatus(channelId, protocolState) {
1424
+ await writePrivateJsonFile(this.resolveSharedProtocolStatusPath(channelId, protocolState.anchorMessageId), {
1425
+ channelId,
1426
+ anchorMessageId: protocolState.anchorMessageId,
1427
+ active: protocolState.active,
1428
+ finished: protocolState.finished,
1429
+ updatedAt: Date.now(),
1430
+ });
1431
+ }
1432
+ async readSharedProtocolStatus(channelId, anchorMessageId) {
1433
+ try {
1434
+ const content = await readFile(this.resolveSharedProtocolStatusPath(channelId, anchorMessageId), 'utf8');
1435
+ const parsed = JSON.parse(content);
1436
+ if (typeof parsed.active !== 'boolean' || typeof parsed.finished !== 'boolean') {
1437
+ return null;
1438
+ }
1439
+ return { active: parsed.active, finished: parsed.finished };
1440
+ }
1441
+ catch (error) {
1442
+ const code = error.code;
1443
+ if (code === 'ENOENT') {
1444
+ return null;
1445
+ }
1446
+ throw error;
1447
+ }
1448
+ }
1449
+ clearProtocolFallback(channelState) {
1450
+ const timer = channelState?.protocol?.fallbackTimer;
1451
+ if (timer) {
1452
+ clearTimeout(timer);
1453
+ if (channelState?.protocol) {
1454
+ channelState.protocol.fallbackTimer = null;
1455
+ }
1456
+ }
1457
+ }
1458
+ armProtocolFallback(channelId, channelState, protocolState) {
1459
+ this.clearProtocolFallback(channelState);
1460
+ const delay = Math.max(0, protocolState.fallbackDeadlineAt - Date.now());
1461
+ protocolState.fallbackTimer = setTimeout(() => {
1462
+ void this.runProtocolFallback(channelId).catch((error) => {
1463
+ this.logger.error('protocol fallback failed', {
1464
+ channelId,
1465
+ error: summarizeError(error),
1466
+ });
1467
+ });
1468
+ }, delay);
1469
+ }
1470
+ async runProtocolFallback(channelId) {
1471
+ const channelState = this.collaborationChannels.get(channelId);
1472
+ const protocolState = channelState?.protocol;
1473
+ const selfAgentId = await this.ensureSelfAgentId();
1474
+ if (!channelState ||
1475
+ !protocolState?.active ||
1476
+ selfAgentId === protocolState.issuerId ||
1477
+ protocolState.expectedNextSpeakerId !== protocolState.issuerId ||
1478
+ protocolState.fallbackDeadlineAt > Date.now()) {
1479
+ return;
1480
+ }
1481
+ await this.handleProtocolFallbackFailure(channelId, channelState, protocolState, selfAgentId);
1482
+ }
1483
+ async handleProtocolFallbackFailure(channelId, channelState, protocolState, selfAgentId) {
1484
+ if (selfAgentId === protocolState.issuerId) {
1485
+ protocolState.fallbackDeadlineAt = nextProtocolFallbackDeadline();
1486
+ await this.persistProtocolState(channelId, protocolState);
1487
+ return;
1488
+ }
1489
+ const fallbackMessage = {
1490
+ channel_id: channelId,
1491
+ message_id: protocolState.anchorMessageId,
1492
+ user_id: protocolState.anchorAuthorId,
1493
+ content: protocolState.anchorContent,
1494
+ body: protocolState.anchorContent,
1495
+ type: 'message',
1496
+ };
1497
+ this.cancelProtocolState(channelId, channelState);
1498
+ await this.runUngatedTurn(fallbackMessage, protocolState.anchorAuthorId, protocolState.anchorContent);
1499
+ }
1500
+ finishProtocolState(channelId, channelState, protocolState, lastMessageId) {
1501
+ protocolState.active = false;
1502
+ protocolState.finished = true;
1503
+ if (lastMessageId) {
1504
+ protocolState.lastProtocolMessageId = lastMessageId;
1505
+ }
1506
+ this.clearProtocolFallback(channelState);
1507
+ void this.persistProtocolState(channelId, protocolState);
1508
+ void this.persistSharedProtocolStatus(channelId, protocolState);
1509
+ }
1510
+ cancelProtocolState(channelId, channelState) {
1511
+ if (channelState.activeTurn) {
1512
+ this.supersedeActiveTurn(channelId, channelState.activeTurn);
1513
+ }
1514
+ if (!channelState.protocol) {
1515
+ return;
1516
+ }
1517
+ channelState.protocol.active = false;
1518
+ channelState.protocol.finished = false;
1519
+ this.clearProtocolFallback(channelState);
1520
+ void this.persistProtocolState(channelId, channelState.protocol);
1521
+ void this.persistSharedProtocolStatus(channelId, channelState.protocol);
1522
+ }
1523
+ async confirmProtocolMessage(channelId, messageId, authorId, expectedReplyToId, createdAt) {
1524
+ const history = await this.borgee.readChannelHistory({
1525
+ channelId,
1526
+ before: createdAt != null ? createdAt + 1 : undefined,
1527
+ limit: 20,
1528
+ });
1529
+ const matched = history.find((entry) => entry.id === messageId);
1530
+ if (!matched) {
1531
+ return null;
1532
+ }
1533
+ if (matched.authorId !== authorId || matched.replyToId !== expectedReplyToId) {
1534
+ return null;
1535
+ }
1536
+ return matched;
1537
+ }
1538
+ async runUngatedTurn(msg, authorId, content) {
1539
+ const executed = await this.executeTurn(msg, authorId, content);
1540
+ if (executed.reply) {
1541
+ this.updateAwaitingUserState(msg.channel_id, executed.reply.awaitingUser);
1542
+ }
1543
+ }
1544
+ async executeTurn(msg, authorId, content, options) {
1545
+ const channelId = msg.channel_id;
1546
+ const activeTurn = options?.activeTurn;
1547
+ const silentTurn = options?.silent === true;
1548
+ const deferDelivery = options?.deferDelivery === true;
1549
+ const collaborationTurnMode = activeTurn?.protocol
1550
+ ? 'protocol-managed'
1551
+ : options?.kickoff
1552
+ ? 'silent-kickoff'
1553
+ : undefined;
1554
+ const turnExecutionId = activeTurn?.turnExecutionId ?? null;
1555
+ const stopTyping = silentTurn ? () => { } : this.borgee.startTyping(channelId);
1556
+ const collaborationGrounding = await this.buildCollaborationGrounding(authorId, options?.incomingAuthorKind, activeTurn, options?.kickoff);
1557
+ const usePublicDraftProgress = !silentTurn &&
1558
+ !deferDelivery &&
1559
+ !this.collaborationEnabled &&
1560
+ options?.incomingAuthorKind !== 'agent' &&
1561
+ providerUsesDraftProgress(this.config.agent.provider) &&
1562
+ activeTurn?.protocol === undefined;
1563
+ const usePrivateCollaborationDraftProgress = !silentTurn &&
1564
+ !deferDelivery &&
1565
+ this.collaborationEnabled &&
1566
+ turnExecutionId != null &&
1567
+ providerUsesDraftProgress(this.config.agent.provider) &&
1568
+ activeTurn?.protocol === undefined;
1569
+ const turnContext = {
1570
+ eventType: msg.type,
1571
+ channelId,
1572
+ messageId: msg.message_id,
1573
+ authorId,
1574
+ contentLength: content.length,
1575
+ provider: this.config.agent.provider,
1576
+ agentName: this.config.agent.agentName,
1577
+ generation: activeTurn?.generation,
1578
+ };
1579
+ const turnStartedAt = Date.now();
1580
+ this.logger.debug('starting turn', turnContext);
1581
+ try {
1582
+ const reply = await this.provider.generateReply({
1583
+ agentName: this.config.agent.agentName,
1584
+ provider: this.config.agent.provider,
1585
+ channelId,
1586
+ incomingAuthorId: authorId,
1587
+ incomingContent: content,
1588
+ incomingEventKind: msg.type,
1589
+ incomingMessageType: msg.message_type,
1590
+ collaboration: turnExecutionId || options?.kickoff
1591
+ ? {
1592
+ enabled: this.collaborationEnabled,
1593
+ ...(turnExecutionId ? { turnExecutionId } : {}),
1594
+ turnMode: collaborationTurnMode ?? 'ordinary',
1595
+ ...(options?.kickoff ? { kickoff: options.kickoff } : {}),
1596
+ ...(activeTurn?.protocol ? { protocol: activeTurn.protocol } : {}),
1597
+ ...(collaborationGrounding ? { grounding: collaborationGrounding } : {}),
1598
+ }
1599
+ : undefined,
1600
+ }, usePublicDraftProgress || usePrivateCollaborationDraftProgress
1601
+ ? {
1602
+ onProgress: (update) => {
1603
+ if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
1604
+ return;
1605
+ }
1606
+ if (usePublicDraftProgress) {
1607
+ this.ensureProgressDraft(channelId, activeTurn?.generation, activeTurn?.delivery).update(update.text);
1608
+ return;
1609
+ }
1610
+ this.publishCollaborationDraft(channelId, activeTurn, update.text);
1611
+ },
1612
+ }
1613
+ : undefined);
1614
+ const publicReplyText = resolvePublicReplyText(reply);
1615
+ const visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
1616
+ if (!(await this.canAcceptCompletedTurnOutput(channelId, activeTurn))) {
1617
+ await this.discardDraft(channelId);
1618
+ if (activeTurn?.turnExecutionId) {
1619
+ this.clearCollaborationDraft(channelId, {
1620
+ generation: activeTurn.generation,
1621
+ turnExecutionId: activeTurn.turnExecutionId,
1622
+ });
1623
+ }
1624
+ return { reply, stale: true, completed: false };
1625
+ }
1626
+ if (silentTurn) {
1627
+ this.logger.debug('completed turn', {
1628
+ ...turnContext,
1629
+ durationMs: Date.now() - turnStartedAt,
1630
+ replyLength: publicReplyText.length,
1631
+ controlKind: reply?.control?.kind ?? null,
1632
+ controlMalformed: reply?.controlMalformed ?? false,
1633
+ delivery: 'silent',
1634
+ });
1635
+ return {
1636
+ reply,
1637
+ deliveredMessageId: null,
1638
+ publicReplyText,
1639
+ visibleReplyBody,
1640
+ stale: false,
1641
+ completed: true,
1642
+ };
1643
+ }
1644
+ if (deferDelivery) {
1645
+ this.logger.debug('completed turn', {
1646
+ ...turnContext,
1647
+ durationMs: Date.now() - turnStartedAt,
1648
+ replyLength: publicReplyText.length,
1649
+ controlKind: reply?.control?.kind ?? null,
1650
+ controlMalformed: reply?.controlMalformed ?? false,
1651
+ delivery: 'deferred',
1652
+ });
1653
+ return {
1654
+ reply,
1655
+ deliveredMessageId: null,
1656
+ publicReplyText,
1657
+ visibleReplyBody,
1658
+ stale: false,
1659
+ completed: true,
1660
+ };
1661
+ }
1662
+ if (usePublicDraftProgress) {
1663
+ const draft = this.progressDrafts.get(channelId);
1664
+ if (!this.started) {
1665
+ await draft?.discard().catch(() => { });
1666
+ this.progressDrafts.delete(channelId);
1667
+ return { reply, stale: true, completed: false };
1668
+ }
1669
+ const deliveredMessageId = await (draft ?? this.ensureProgressDraft(channelId, activeTurn?.generation, activeTurn?.delivery)).finalize(publicReplyText);
1670
+ this.progressDrafts.delete(channelId);
1671
+ this.logger.debug('completed turn', {
1672
+ ...turnContext,
1673
+ durationMs: Date.now() - turnStartedAt,
1674
+ replyLength: publicReplyText.length,
1675
+ controlKind: reply?.control?.kind ?? null,
1676
+ controlMalformed: reply?.controlMalformed ?? false,
1677
+ delivery: 'draft',
1678
+ });
1679
+ return {
1680
+ reply,
1681
+ deliveredMessageId,
1682
+ publicReplyText,
1683
+ visibleReplyBody,
1684
+ stale: false,
1685
+ completed: true,
1686
+ };
1687
+ }
1688
+ else if (this.started && !this.controlPlaneClosed && hasVisibleText(publicReplyText)) {
1689
+ const posted = await this.borgee.postMessage({
1690
+ channelId,
1691
+ body: visibleReplyBody,
1692
+ replyToId: activeTurn?.delivery?.replyToId,
1693
+ });
1694
+ this.logger.debug('completed turn', {
1695
+ ...turnContext,
1696
+ durationMs: Date.now() - turnStartedAt,
1697
+ replyLength: publicReplyText.length,
1698
+ controlKind: reply?.control?.kind ?? null,
1699
+ controlMalformed: reply?.controlMalformed ?? false,
1700
+ delivery: 'post',
1701
+ });
1702
+ if (usePrivateCollaborationDraftProgress) {
1703
+ this.finalizeCollaborationDraft(channelId, activeTurn, posted.messageId, visibleReplyBody);
1704
+ }
1705
+ return {
1706
+ reply,
1707
+ deliveredMessageId: posted.messageId,
1708
+ publicReplyText,
1709
+ visibleReplyBody,
1710
+ stale: false,
1711
+ completed: true,
1712
+ };
1713
+ }
1714
+ this.logger.debug('completed turn', {
1715
+ ...turnContext,
1716
+ durationMs: Date.now() - turnStartedAt,
1717
+ replyLength: publicReplyText.length,
1718
+ delivery: usePublicDraftProgress ? 'draft' : 'post',
1719
+ });
1720
+ if (usePrivateCollaborationDraftProgress && activeTurn?.turnExecutionId) {
1721
+ this.clearCollaborationDraft(channelId, {
1722
+ generation: activeTurn.generation,
1723
+ turnExecutionId: activeTurn.turnExecutionId,
1724
+ });
1725
+ }
1726
+ return {
1727
+ reply,
1728
+ deliveredMessageId: null,
1729
+ publicReplyText,
1730
+ visibleReplyBody,
1731
+ stale: false,
1732
+ completed: true,
1733
+ };
1734
+ }
1735
+ catch (error) {
1736
+ await this.discardDraft(channelId);
1737
+ if (activeTurn?.turnExecutionId) {
1738
+ this.clearCollaborationDraft(channelId, {
1739
+ generation: activeTurn.generation,
1740
+ turnExecutionId: activeTurn.turnExecutionId,
1741
+ });
1742
+ }
1743
+ this.logger.debugError('failed to generate or send reply', {
1744
+ ...turnContext,
1745
+ error,
1746
+ });
1747
+ this.logger.error('failed to generate or send reply', {
1748
+ ...turnContext,
1749
+ error: summarizeError(error),
1750
+ });
1751
+ return { stale: activeTurn?.superseded === true, completed: false };
1752
+ }
1753
+ finally {
1754
+ stopTyping();
1755
+ }
1756
+ }
1757
+ canAcceptTurnOutput(channelId, generation) {
1758
+ if (!this.started || this.controlPlaneClosed) {
1759
+ return false;
1760
+ }
1761
+ if (generation == null) {
1762
+ return true;
1763
+ }
1764
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
1765
+ return (activeTurn?.generation === generation &&
1766
+ !activeTurn.superseded &&
1767
+ activeTurn.turnExecutionId != null);
1768
+ }
1769
+ async canAcceptCompletedTurnOutput(channelId, activeTurn) {
1770
+ if (!this.canAcceptTurnOutput(channelId, activeTurn?.generation)) {
1771
+ return false;
1772
+ }
1773
+ if (!activeTurn?.protocol) {
1774
+ return true;
1775
+ }
1776
+ const channelState = this.collaborationChannels.get(channelId);
1777
+ if (!channelState?.protocol) {
1778
+ return false;
1779
+ }
1780
+ if ((await this.canContinueSharedProtocol(channelId, channelState, channelState.protocol)) &&
1781
+ channelState.protocol.anchorMessageId === activeTurn.protocol.anchorMessageId) {
1782
+ return true;
1783
+ }
1784
+ this.supersedeActiveTurn(channelId, activeTurn);
1785
+ return false;
1786
+ }
1787
+ async canContinueSharedProtocol(channelId, channelState, protocolState) {
1788
+ const sharedStatus = await this.readSharedProtocolStatus(channelId, protocolState.anchorMessageId);
1789
+ if (sharedStatus?.active !== false) {
1790
+ return protocolState.active;
1791
+ }
1792
+ protocolState.active = false;
1793
+ protocolState.finished = sharedStatus.finished;
1794
+ channelState.queue = channelState.queue.filter((message) => message.kind !== 'protocol-turn');
1795
+ channelState.recentTurn = undefined;
1796
+ this.clearProtocolFallback(channelState);
1797
+ return false;
1798
+ }
1799
+ enqueueProgressChannelTurn(channelId, task) {
1800
+ const previous = this.progressChannelQueues.get(channelId) ?? Promise.resolve();
1801
+ const next = previous
1802
+ .catch(() => { })
1803
+ .then(task)
1804
+ .finally(() => {
1805
+ if (this.progressChannelQueues.get(channelId) === next) {
1806
+ this.progressChannelQueues.delete(channelId);
1807
+ }
1808
+ });
1809
+ this.progressChannelQueues.set(channelId, next);
1810
+ return next;
1811
+ }
1812
+ ensureProgressDraft(channelId, generation, creationMetadata) {
1813
+ let draft = this.progressDrafts.get(channelId);
1814
+ if (!draft) {
1815
+ draft = new DraftMessageController(this.borgee, channelId, () => !this.controlPlaneClosed && this.canAcceptTurnOutput(channelId, generation), this.logger, creationMetadata);
1816
+ this.progressDrafts.set(channelId, draft);
1817
+ }
1818
+ return draft;
1819
+ }
1820
+ publishCollaborationDraft(channelId, activeTurn, body) {
1821
+ if (!activeTurn?.turnExecutionId || !hasVisibleText(body)) {
1822
+ return;
1823
+ }
1824
+ const generation = activeTurn.generation;
1825
+ const existing = this.collaborationDrafts.get(channelId);
1826
+ if (existing &&
1827
+ (existing.generation !== generation || existing.turnExecutionId !== activeTurn.turnExecutionId)) {
1828
+ this.clearCollaborationDraft(channelId);
1829
+ }
1830
+ const next = this.collaborationDrafts.get(channelId);
1831
+ if (next?.clearTimer) {
1832
+ clearTimeout(next.clearTimer);
1833
+ next.clearTimer = null;
1834
+ }
1835
+ const snapshot = next?.snapshot ?? {
1836
+ draftId: randomUUID(),
1837
+ channelId,
1838
+ turnExecutionId: activeTurn.turnExecutionId,
1839
+ replyToId: activeTurn.delivery?.replyToId,
1840
+ body,
1841
+ status: 'streaming',
1842
+ updatedAt: Date.now(),
1843
+ };
1844
+ snapshot.body = body;
1845
+ snapshot.status = 'streaming';
1846
+ snapshot.updatedAt = Date.now();
1847
+ delete snapshot.finalMessageId;
1848
+ this.collaborationDrafts.set(channelId, {
1849
+ generation,
1850
+ turnExecutionId: activeTurn.turnExecutionId,
1851
+ snapshot,
1852
+ clearTimer: null,
1853
+ });
1854
+ }
1855
+ finalizeCollaborationDraft(channelId, activeTurn, finalMessageId, finalBody) {
1856
+ if (!activeTurn?.turnExecutionId) {
1857
+ this.clearCollaborationDraft(channelId);
1858
+ return;
1859
+ }
1860
+ if (!finalMessageId || !hasVisibleText(finalBody)) {
1861
+ this.clearCollaborationDraft(channelId, {
1862
+ generation: activeTurn.generation,
1863
+ turnExecutionId: activeTurn.turnExecutionId,
1864
+ });
1865
+ return;
1866
+ }
1867
+ const draft = this.collaborationDrafts.get(channelId);
1868
+ if (!draft ||
1869
+ draft.generation !== activeTurn.generation ||
1870
+ draft.turnExecutionId !== activeTurn.turnExecutionId) {
1871
+ return;
1872
+ }
1873
+ if (draft.clearTimer) {
1874
+ clearTimeout(draft.clearTimer);
1875
+ }
1876
+ draft.snapshot.body = finalBody;
1877
+ draft.snapshot.status = 'finalized';
1878
+ draft.snapshot.updatedAt = Date.now();
1879
+ draft.snapshot.finalMessageId = finalMessageId;
1880
+ draft.clearTimer = setTimeout(() => {
1881
+ this.clearCollaborationDraft(channelId, {
1882
+ generation: activeTurn.generation,
1883
+ turnExecutionId: activeTurn.turnExecutionId,
1884
+ });
1885
+ }, COLLABORATION_LATE_SEND_GRACE_MS);
1886
+ }
1887
+ clearCollaborationDraft(channelId, expected) {
1888
+ const draft = this.collaborationDrafts.get(channelId);
1889
+ if (!draft) {
1890
+ return;
1891
+ }
1892
+ if (expected &&
1893
+ (draft.generation !== expected.generation || draft.turnExecutionId !== expected.turnExecutionId)) {
1894
+ return;
1895
+ }
1896
+ if (draft.clearTimer) {
1897
+ clearTimeout(draft.clearTimer);
1898
+ }
1899
+ this.collaborationDrafts.delete(channelId);
1900
+ }
1901
+ readCollaborationDraft(input) {
1902
+ const draft = this.collaborationDrafts.get(input.channelId);
1903
+ if (!draft || draft.turnExecutionId !== input.turnExecutionId) {
1904
+ return null;
1905
+ }
1906
+ return { ...draft.snapshot };
1907
+ }
1908
+ async discardDraft(channelId) {
1909
+ const draft = this.progressDrafts.get(channelId);
1910
+ if (!draft) {
1911
+ return;
1912
+ }
1913
+ this.progressDrafts.delete(channelId);
1914
+ await draft.discard().catch(() => { });
1915
+ }
1916
+ supersedeActiveTurn(channelId, activeTurn) {
1917
+ if (activeTurn.superseded) {
1918
+ return;
1919
+ }
1920
+ if (activeTurn.turnExecutionId) {
1921
+ this.clearCollaborationDraft(channelId, {
1922
+ generation: activeTurn.generation,
1923
+ turnExecutionId: activeTurn.turnExecutionId,
1924
+ });
1925
+ }
1926
+ activeTurn.superseded = true;
1927
+ activeTurn.turnExecutionId = null;
1928
+ void this.discardDraft(channelId);
1929
+ }
1930
+ invalidateChannelTurn(channelId) {
1931
+ const activeTurn = this.collaborationChannels.get(channelId)?.activeTurn;
1932
+ if (activeTurn) {
1933
+ activeTurn.turnExecutionId = null;
1934
+ }
1935
+ }
1936
+ rememberRecentTurn(channelState, activeTurn) {
1937
+ if (!activeTurn.turnExecutionId) {
1938
+ channelState.recentTurn = undefined;
1939
+ return;
1940
+ }
1941
+ channelState.recentTurn = {
1942
+ turnExecutionId: activeTurn.turnExecutionId,
1943
+ superseded: activeTurn.superseded,
1944
+ collaborationMessagesSent: activeTurn.collaborationMessagesSent,
1945
+ targetCooldowns: new Map(activeTurn.targetCooldowns),
1946
+ ...(activeTurn.protocol ? { protocol: activeTurn.protocol } : {}),
1947
+ expiresAt: Date.now() + COLLABORATION_LATE_SEND_GRACE_MS,
1948
+ };
1949
+ }
1950
+ resolveAuthorizedCollaborationTurn(channelState, turnExecutionId) {
1951
+ const activeTurn = channelState?.activeTurn;
1952
+ if (activeTurn &&
1953
+ !activeTurn.superseded &&
1954
+ activeTurn.turnExecutionId != null &&
1955
+ activeTurn.turnExecutionId === turnExecutionId) {
1956
+ return activeTurn;
1957
+ }
1958
+ const recentTurn = channelState?.recentTurn;
1959
+ if (!recentTurn) {
1960
+ return undefined;
1961
+ }
1962
+ if (recentTurn.expiresAt <= Date.now()) {
1963
+ channelState.recentTurn = undefined;
1964
+ return undefined;
1965
+ }
1966
+ if (!recentTurn.superseded && recentTurn.turnExecutionId === turnExecutionId) {
1967
+ return recentTurn;
1968
+ }
1969
+ return undefined;
1970
+ }
1971
+ updateAwaitingUserState(channelId, awaitingUser) {
1972
+ if (awaitingUser) {
1973
+ this.awaitingUserByChannel.set(channelId, awaitingUser);
1974
+ return;
1975
+ }
1976
+ this.awaitingUserByChannel.delete(channelId);
1977
+ }
1978
+ resolveVisibleTurnBody(activeTurn, reply, publicReplyText) {
1979
+ if (!activeTurn?.protocol || !this.shouldRelayProtocolReply(activeTurn, reply)) {
1980
+ return publicReplyText;
1981
+ }
1982
+ return appendVisibleBodyMention(publicReplyText, activeTurn.protocol.targetPeerId);
1983
+ }
1984
+ shouldRelayProtocolReply(activeTurn, reply) {
1985
+ const protocol = activeTurn.protocol;
1986
+ return (protocol != null &&
1987
+ reply.control?.kind === 'continue-to-peer' &&
1988
+ protocol.turnIndex < protocol.maxTurnCount);
1989
+ }
1990
+ async normalizeMessage(msg) {
1991
+ this.logger.debug('received channel event', {
1992
+ eventType: msg.type,
1993
+ messageType: msg.message_type,
1994
+ provider: this.config.agent.provider,
1995
+ agentName: this.config.agent.agentName,
1996
+ channelId: msg.channel_id,
1997
+ messageId: msg.message_id,
1998
+ });
1999
+ if (!isReplyTriggeringEventType(msg.type)) {
2000
+ this.logger.debug('ignored non-triggering channel event', {
2001
+ eventType: msg.type,
2002
+ messageType: msg.message_type,
2003
+ channelId: msg.channel_id,
2004
+ messageId: msg.message_id,
2005
+ });
2006
+ return null;
2007
+ }
2008
+ const selfAgentId = await this.ensureSelfAgentId();
2009
+ const authorId = String(msg.user_id ?? msg.sender_id ?? 'unknown');
2010
+ if (authorId === selfAgentId && msg.message_type !== 'task_assignment') {
2011
+ this.logger.debug('ignored self-authored message', {
2012
+ channelId: msg.channel_id,
2013
+ messageId: msg.message_id,
2014
+ });
2015
+ return null;
2016
+ }
2017
+ const content = String(msg.content ?? msg.body ?? '').trim();
2018
+ if (!content) {
2019
+ this.logger.debug('ignored empty message', {
2020
+ channelId: msg.channel_id,
2021
+ messageId: msg.message_id,
2022
+ });
2023
+ return null;
2024
+ }
2025
+ return {
2026
+ authorId,
2027
+ content,
2028
+ };
2029
+ }
2030
+ async markAssignedTaskInProgress(threadId, content) {
2031
+ try {
2032
+ const selfAgentId = await this.ensureSelfAgentId();
2033
+ const task = await waitForTaskForThread(this.borgee, threadId, {
2034
+ preferredTaskId: extractTaskIdFromTaskAssignmentContent(content),
2035
+ attempts: 10,
2036
+ delayMs: 250,
2037
+ });
2038
+ if (!task || task.status !== 'open' || task.assigneeId !== selfAgentId) {
2039
+ return;
2040
+ }
2041
+ await this.borgee.updateTask({
2042
+ taskId: task.id,
2043
+ status: 'in_progress',
2044
+ });
2045
+ }
2046
+ catch (error) {
2047
+ this.logger.debugError('best-effort task auto-start failed', {
2048
+ channelId: threadId,
2049
+ error,
2050
+ });
2051
+ }
2052
+ }
2053
+ async ensureSelfAgentId() {
2054
+ if (this.selfAgentId) {
2055
+ return this.selfAgentId;
2056
+ }
2057
+ if (!this.selfAgentIdPromise) {
2058
+ this.selfAgentIdPromise = this.borgee
2059
+ .getMe()
2060
+ .then((me) => {
2061
+ this.selfAgentId = me.id;
2062
+ this.selfAgentProfile = me;
2063
+ this.participantKindsByUserId.set(me.id, 'agent');
2064
+ this.participantDirectoryByUserId.set(me.id, {
2065
+ id: me.id,
2066
+ display_name: me.display_name,
2067
+ kind: 'agent',
2068
+ });
2069
+ return me.id;
2070
+ })
2071
+ .catch((error) => {
2072
+ this.selfAgentIdPromise = null;
2073
+ throw error;
2074
+ });
2075
+ }
2076
+ return this.selfAgentIdPromise;
2077
+ }
2078
+ async classifyAuthor(authorId, forceRefreshOnMiss) {
2079
+ const cached = this.participantKindsByUserId.get(authorId);
2080
+ if (cached) {
2081
+ return cached === 'agent' ? 'agent' : 'human';
2082
+ }
2083
+ if (!this.participantDirectorySupported) {
2084
+ return this.inferAuthorKind(authorId);
2085
+ }
2086
+ const now = Date.now();
2087
+ const nextRefreshAt = this.participantRefreshBackoffByUserId.get(authorId) ?? 0;
2088
+ if (forceRefreshOnMiss || now >= nextRefreshAt) {
2089
+ await this.refreshParticipantDirectory();
2090
+ const refreshed = this.participantKindsByUserId.get(authorId);
2091
+ if (refreshed) {
2092
+ this.participantRefreshBackoffByUserId.delete(authorId);
2093
+ return refreshed === 'agent' ? 'agent' : 'human';
2094
+ }
2095
+ this.participantRefreshBackoffByUserId.set(authorId, now + BLOCKED_AUTHOR_REFRESH_BACKOFF_MS);
2096
+ }
2097
+ return 'unknown';
2098
+ }
2099
+ async refreshParticipantDirectory() {
2100
+ if (!this.participantDirectorySupported) {
2101
+ return;
2102
+ }
2103
+ if (!this.participantRefreshPromise) {
2104
+ this.participantRefreshPromise = this.borgee
2105
+ .listUsers()
2106
+ .then((users) => {
2107
+ const nextKinds = new Map();
2108
+ const nextDirectory = new Map();
2109
+ for (const user of users) {
2110
+ nextKinds.set(user.id, user.kind);
2111
+ nextDirectory.set(user.id, user);
2112
+ }
2113
+ if (this.selfAgentId) {
2114
+ nextKinds.set(this.selfAgentId, 'agent');
2115
+ nextDirectory.set(this.selfAgentId, {
2116
+ id: this.selfAgentId,
2117
+ display_name: this.selfAgentProfile?.display_name,
2118
+ kind: 'agent',
2119
+ });
2120
+ }
2121
+ this.participantKindsByUserId.clear();
2122
+ this.participantDirectoryByUserId.clear();
2123
+ for (const [userId, kind] of nextKinds) {
2124
+ this.participantKindsByUserId.set(userId, kind);
2125
+ }
2126
+ for (const [userId, user] of nextDirectory) {
2127
+ this.participantDirectoryByUserId.set(userId, user);
2128
+ }
2129
+ })
2130
+ .catch((error) => {
2131
+ if (this.isUnsupportedSemanticOperation(error, 'list_users')) {
2132
+ this.participantDirectorySupported = false;
2133
+ return;
2134
+ }
2135
+ this.logger.error('failed to refresh participant directory', {
2136
+ error: summarizeError(error),
2137
+ });
2138
+ })
2139
+ .finally(() => {
2140
+ this.participantRefreshPromise = null;
2141
+ });
2142
+ }
2143
+ await this.participantRefreshPromise;
2144
+ }
2145
+ async classifyBlockedWakeAuthor(authorId, blockedState) {
2146
+ void blockedState;
2147
+ return this.classifyAuthor(authorId, true);
2148
+ }
2149
+ async classifyQueuedBlockedWakeCandidate(message, blockedState) {
2150
+ void blockedState;
2151
+ return this.classifyAuthor(message.authorId, true);
2152
+ }
2153
+ inferAuthorKind(authorId) {
2154
+ if (this.selfAgentId === authorId) {
2155
+ return 'agent';
2156
+ }
2157
+ if (authorId.includes('@')) {
2158
+ return 'human';
2159
+ }
2160
+ if (authorId.startsWith('user-') || authorId.startsWith('human-')) {
2161
+ return 'human';
2162
+ }
2163
+ if (authorId.startsWith('agent-')) {
2164
+ return 'agent';
2165
+ }
2166
+ return 'unknown';
2167
+ }
2168
+ async buildCollaborationGrounding(authorId, incomingAuthorKind, activeTurn, kickoff) {
2169
+ if (!activeTurn?.protocol && !kickoff) {
2170
+ return undefined;
2171
+ }
2172
+ const selfAgentId = await this.ensureSelfAgentId();
2173
+ const peerId = activeTurn?.protocol?.targetPeerId ??
2174
+ kickoff?.participantIds.find((participantId) => participantId !== selfAgentId);
2175
+ const participantIds = kickoff?.participantIds ?? (peerId ? [selfAgentId, peerId] : [selfAgentId]);
2176
+ await this.hydratePromptIdentityCache([selfAgentId, authorId, ...participantIds]);
2177
+ return {
2178
+ self: this.resolvePromptIdentity(selfAgentId, 'agent'),
2179
+ incomingAuthor: this.resolvePromptIdentity(authorId, incomingAuthorKind ?? 'unknown'),
2180
+ ...(peerId ? { peer: this.resolvePromptIdentity(peerId, 'agent') } : {}),
2181
+ participants: participantIds.map((participantId) => this.resolvePromptIdentity(participantId, 'agent')),
2182
+ ...(kickoff ? { hostRecognizedKickoffCandidate: true } : {}),
2183
+ };
2184
+ }
2185
+ async hydratePromptIdentityCache(userIds) {
2186
+ if (!this.participantDirectorySupported) {
2187
+ return;
2188
+ }
2189
+ const unresolved = [...new Set(userIds)].filter((userId) => !this.participantDirectoryByUserId.has(userId));
2190
+ if (unresolved.length === 0) {
2191
+ return;
2192
+ }
2193
+ await this.refreshParticipantDirectory();
2194
+ }
2195
+ resolvePromptIdentity(userId, fallbackKind) {
2196
+ const directoryUser = this.participantDirectoryByUserId.get(userId);
2197
+ if (this.selfAgentId === userId) {
2198
+ return {
2199
+ id: userId,
2200
+ displayName: this.selfAgentProfile?.display_name ??
2201
+ directoryUser?.display_name ??
2202
+ this.config.agent.agentName,
2203
+ kind: 'agent',
2204
+ };
2205
+ }
2206
+ return {
2207
+ id: userId,
2208
+ displayName: directoryUser?.display_name,
2209
+ kind: (directoryUser?.kind === 'user' ? 'human' : directoryUser?.kind) ?? fallbackKind,
2210
+ };
2211
+ }
2212
+ isUnsupportedSemanticOperation(error, op) {
2213
+ if (!error || typeof error !== 'object') {
2214
+ return false;
2215
+ }
2216
+ const code = 'code' in error ? error.code : undefined;
2217
+ const message = 'message' in error ? error.message : undefined;
2218
+ return (code === 'bpp.semantic_op_unknown' && typeof message === 'string' && message.includes(op));
2219
+ }
2220
+ authorizeCollaborationSend(input) {
2221
+ if (!this.collaborationEnabled) {
2222
+ return { ok: false, statusCode: 403, error: 'collaboration_not_enabled' };
2223
+ }
2224
+ const channelState = this.collaborationChannels.get(input.channelId);
2225
+ const authorizedTurn = this.resolveAuthorizedCollaborationTurn(channelState, input.turnExecutionId);
2226
+ if (!authorizedTurn) {
2227
+ return { ok: false, statusCode: 409, error: 'stale_turn_execution_id' };
2228
+ }
2229
+ if (authorizedTurn.protocol) {
2230
+ return { ok: false, statusCode: 403, error: 'protocol_managed_turn' };
2231
+ }
2232
+ if (authorizedTurn.collaborationMessagesSent >= COLLABORATION_SENDS_PER_TURN) {
2233
+ return { ok: false, statusCode: 429, error: 'collaboration_quota_exceeded' };
2234
+ }
2235
+ const targetKey = [
2236
+ input.replyToId ? `reply:${input.replyToId}` : '',
2237
+ input.mentions.length > 0 ? `mentions:${[...new Set(input.mentions)].sort().join(',')}` : '',
2238
+ ]
2239
+ .filter((value) => value.length > 0)
2240
+ .join('|');
2241
+ const existingCooldownUntil = authorizedTurn.targetCooldowns.get(targetKey) ?? 0;
2242
+ if (existingCooldownUntil > Date.now()) {
2243
+ return { ok: false, statusCode: 429, error: 'collaboration_target_cooldown' };
2244
+ }
2245
+ const cooldownUntil = Date.now() + LOCALHOST_GATEWAY_COLLABORATION_TARGET_COOLDOWN_MS;
2246
+ authorizedTurn.collaborationMessagesSent += 1;
2247
+ authorizedTurn.targetCooldowns.set(targetKey, cooldownUntil);
2248
+ let settled = false;
2249
+ return {
2250
+ ok: true,
2251
+ statusCode: 200,
2252
+ commit: () => {
2253
+ settled = true;
2254
+ },
2255
+ rollback: () => {
2256
+ if (settled) {
2257
+ return;
2258
+ }
2259
+ settled = true;
2260
+ authorizedTurn.collaborationMessagesSent = Math.max(0, authorizedTurn.collaborationMessagesSent - 1);
2261
+ if (authorizedTurn.targetCooldowns.get(targetKey) === cooldownUntil) {
2262
+ authorizedTurn.targetCooldowns.delete(targetKey);
2263
+ }
2264
+ },
2265
+ };
308
2266
  }
309
2267
  }