@borgee/agents-host 0.2.33 → 0.2.44

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 (60) hide show
  1. package/README.md +28 -7
  2. package/dist/agents-host.d.ts +19 -0
  3. package/dist/agents-host.js +163 -44
  4. package/dist/chat/chat-control-plane.d.ts +9 -0
  5. package/dist/chat/sdk-chat-control-plane.d.ts +10 -1
  6. package/dist/chat/sdk-chat-control-plane.js +9 -0
  7. package/dist/cli-args.d.ts +1 -1
  8. package/dist/cli-args.js +5 -0
  9. package/dist/compatibility-gates.d.ts +1 -0
  10. package/dist/compatibility-gates.js +2 -0
  11. package/dist/config.d.ts +4 -1
  12. package/dist/config.js +21 -3
  13. package/dist/context/injection.d.ts +5 -3
  14. package/dist/context/injection.js +45 -29
  15. package/dist/context/main-session-delegation.d.ts +1 -0
  16. package/dist/context/main-session-delegation.js +6 -0
  17. package/dist/context/prompt.js +25 -35
  18. package/dist/context/skill-manual.d.ts +13 -0
  19. package/dist/context/skill-manual.js +18 -0
  20. package/dist/context/turn-preparation.js +13 -4
  21. package/dist/gateway/localhost-gateway.js +75 -1
  22. package/dist/hosted-turn-content.d.ts +15 -0
  23. package/dist/hosted-turn-content.js +50 -0
  24. package/dist/local-config.js +10 -1
  25. package/dist/managed-daemon.d.ts +3 -2
  26. package/dist/managed-daemon.js +82 -29
  27. package/dist/plugin-sdk.js +58 -1
  28. package/dist/plugin-sdk.js.map +2 -2
  29. package/dist/policy/gateway-authorization.d.ts +18 -3
  30. package/dist/policy/gateway-authorization.js +33 -1
  31. package/dist/providers/claude/adapter.d.ts +3 -1
  32. package/dist/providers/claude/adapter.js +13 -1
  33. package/dist/providers/claude/cli-client.d.ts +36 -3
  34. package/dist/providers/claude/cli-client.js +225 -37
  35. package/dist/providers/codex/adapter.d.ts +3 -1
  36. package/dist/providers/codex/adapter.js +13 -1
  37. package/dist/providers/codex/cli-client.d.ts +35 -3
  38. package/dist/providers/codex/cli-client.js +212 -31
  39. package/dist/providers/codex/project-doc.js +16 -29
  40. package/dist/providers/copilot/adapter.d.ts +3 -1
  41. package/dist/providers/copilot/adapter.js +13 -1
  42. package/dist/providers/copilot/cli-client.d.ts +34 -2
  43. package/dist/providers/copilot/cli-client.js +196 -18
  44. package/dist/providers/create-provider.d.ts +1 -1
  45. package/dist/providers/create-provider.js +30 -14
  46. package/dist/providers/idle-backend-shutdown.d.ts +16 -0
  47. package/dist/providers/idle-backend-shutdown.js +53 -0
  48. package/dist/providers/provider-adapter.d.ts +35 -0
  49. package/dist/providers/provider-adapter.js +44 -1
  50. package/dist/state-paths.d.ts +9 -1
  51. package/dist/state-paths.js +22 -3
  52. package/dist/types.d.ts +40 -2
  53. package/package.json +2 -2
  54. package/skills/borgee-agent/SKILL.md +133 -35
  55. package/skills/borgee-agent/references/errors.md +38 -0
  56. package/skills/borgee-agent/references/task-properties.md +30 -0
  57. package/skills/borgee-agent/scripts/borgee-agent.mjs +553 -0
  58. package/skills/borgee-agent/scripts/borgee-agent.py +547 -0
  59. package/skills/borgee-agent/borgee-agent.mjs +0 -507
  60. package/skills/borgee-agent/borgee-agent.py +0 -438
package/README.md CHANGED
@@ -89,7 +89,7 @@ The current shipped internal compatibility surface resolves gates in this order:
89
89
  2. add any explicit entries from `AGENTS_HOST_INTERNAL_COMPATIBILITY_GATES`
90
90
  3. subtract any explicit entries from `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES`
91
91
 
92
- The shipped default set now also includes `connections-state-layer`, `context-injection`, `skill-runtime`, `localhost-gateway`, `token-binding`, `collaboration-outcome-model`, `task-thread-collaboration-contract`, and `collaboration-capabilities-diagnostics`. `attention-follow-semantics`, `collaboration-skill-first`, and hosted `praestoclaw-provider` still remain opt-in.
92
+ The shipped default set now also includes `connections-state-layer`, `context-injection`, `skill-runtime`, `localhost-gateway`, `token-binding`, `collaboration-outcome-model`, `task-thread-collaboration-contract`, `collaboration-capabilities-diagnostics`, and `provider-idle-shutdown`. `attention-follow-semantics`, `collaboration-skill-first`, and hosted `praestoclaw-provider` still remain opt-in.
93
93
 
94
94
  Managed-runtime settings still preserve the managed rollback rule for `connections-state-layer` plus `token-binding`: when `managed-runtime-convergence` remains enabled, the managed surface forces that pair on; explicitly disabling `managed-runtime-convergence` removes that pair from the managed surface again unless you also explicitly re-enable them.
95
95
 
@@ -101,6 +101,7 @@ Rollback and hardening controls stay explicit:
101
101
  - `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=<comma-list>` disables shipped gates for rollback
102
102
  - `AGENTS_HOST_INTERNAL_PROVIDER_IMPLEMENTATIONS=copilot:v1` forces Copilot back to the v1 adapter while its v2 gate stays enabled; stale `claude:v1|v2` entries are tolerated as no-ops for compatibility
103
103
  - `AGENTS_HOST_INTERNAL_POLICY_MODE=enforce` hardens the shipped `policy-audit-enforcement` gate beyond its default `audit-only` mode
104
+ - `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown` keeps every provider's shared ACP adapter process resident again; `PROVIDER_IDLE_SHUTDOWN_MINUTES=0` does the same without touching gates
104
105
 
105
106
  Or with the CLI:
106
107
 
@@ -240,6 +241,9 @@ Notes:
240
241
  | `CODEX_COMMAND` / `CODEX_ARGS` | `--codex-command` / `--codex-args` | no | `codex-acp` / empty | Local Codex ACP adapter command + args. The default launch resolves the bundled `@agentclientprotocol/codex-acp` entrypoint. |
241
242
  | `COPILOT_COMMAND` / `COPILOT_ARGS` | `--copilot-command` / `--copilot-args` | no | `copilot` / parsed but ignored by ACP runtime | Local Copilot CLI command. The persistent Copilot ACP prototype always launches `copilot --acp`. |
242
243
  | `COPILOT_SESSION_TTL_MINUTES` | `--copilot-session-ttl-minutes` | no | `2880` | Idle TTL for per-channel Copilot ACP sessions |
244
+ | `PROVIDER_IDLE_SHUTDOWN_MINUTES` | `--provider-idle-shutdown-minutes` | no | `10` | Idle window before the agent's shared ACP adapter **process** is shut down and re-spawned on the next turn. Applies to Claude, Codex, and Copilot. `0` keeps the process resident. Must be non-negative and no larger than the Node.js timer limit (~35791.39 minutes); anything else is rejected at startup. |
245
+
246
+ `COPILOT_SESSION_TTL_MINUTES` and `PROVIDER_IDLE_SHUTDOWN_MINUTES` are two different layers and do not replace each other. The first evicts an individual idle Copilot ACP *session* inside a running adapter process. The second shuts the shared adapter *process* itself down once every channel it hosts is idle, for all three providers, and takes every live session in that process with it. See [Conversation memory](#conversation-memory) for what happens to those sessions afterwards.
243
247
 
244
248
  ## Local-config mode
245
249
 
@@ -325,7 +329,8 @@ The spec shape is:
325
329
  "codexArgs": [],
326
330
  "copilotCommand": "copilot",
327
331
  "copilotArgs": ["-s", "--no-color", "--allow-all-tools", "--output-format", "text"],
328
- "copilotSessionTtlMinutes": 2880
332
+ "copilotSessionTtlMinutes": 2880,
333
+ "providerIdleShutdownMinutes": 10
329
334
  }
330
335
  },
331
336
  "agents": [
@@ -398,6 +403,7 @@ defaults:
398
403
  - --output-format
399
404
  - text
400
405
  copilotSessionTtlMinutes: 2880
406
+ providerIdleShutdownMinutes: 10
401
407
  ```
402
408
 
403
409
  Host config fields:
@@ -411,6 +417,7 @@ Host config fields:
411
417
  - `defaults.copilotCommand`
412
418
  - `defaults.copilotArgs`
413
419
  - `defaults.copilotSessionTtlMinutes`
420
+ - `defaults.providerIdleShutdownMinutes`
414
421
 
415
422
  Absent host defaults fall back to the same code-level defaults used by the
416
423
  standalone env flow, foreground `start`, and `start-managed`.
@@ -434,8 +441,8 @@ Agent config fields:
434
441
  - `enabled` (optional, default `true`)
435
442
  - `claudeCommand` / `claudeArgs` (optional): leave unset to use the shipped bundled `claude-agent-acp` path. On that bundled path `claudeArgs` must stay empty. Non-empty `claudeArgs` are only supported when `claudeCommand` points at a custom ACP adapter command.
436
443
  - optional overrides for `claudeCommand`, `claudeArgs`, `codexCommand`,
437
- `codexArgs`, `copilotCommand`, `copilotArgs`, and
438
- `copilotSessionTtlMinutes`
444
+ `codexArgs`, `copilotCommand`, `copilotArgs`,
445
+ `copilotSessionTtlMinutes`, and `providerIdleShutdownMinutes`
439
446
 
440
447
  Array overrides are **replacement**, not concatenation. For example, an agent
441
448
  `claudeArgs` value replaces the host default `claudeArgs` entirely.
@@ -504,9 +511,23 @@ active:
504
511
  state root and restores same-host continuity with ACP `session/resume` when
505
512
  available, otherwise `session/load` plus local replay cleanup on runtimes
506
513
  that advertise only load support.
507
- There is no separate transcript/history store on our side. Claude, Codex, and
508
- Copilot only persist the native session ids they should try to resume later;
509
- cross-host continuity still does not exist in this runtime.
514
+
515
+ We keep no transcript or history store of our own — only the native session id each provider should try to reopen next time. The transcripts themselves live in each CLI's own on-disk session store: `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for Claude, `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<timestamp>-<threadId>.jsonl` for Codex, and `~/.copilot/session-state/<guid>/` for Copilot. Because those stores are on disk rather than in the adapter process, a session can be reopened later by a *different* process on the same machine — which is what makes conversation memory survive both a host restart and the idle recycle below. Continuity is same-machine and same-`$HOME` only: another machine, or a different home directory, has no session to reopen.
516
+
517
+ ### Idle adapter shutdown
518
+
519
+ Each of those subprocesses is shut down once every channel it hosts has been idle for `PROVIDER_IDLE_SHUTDOWN_MINUTES` (default `10`), and a fresh one is spawned lazily on the next turn. "Idle" means the host has no active turn, no queued turn, no ACP session start in flight, and nothing still being processed for any channel of that agent; a turn that arrives mid-shutdown waits for the teardown and then starts the replacement rather than failing. Only the provider subprocess is recycled — the agent stays connected to Borgee, the loopback gateway keeps running, and the agents-host process itself is untouched.
520
+
521
+ Conversation memory normally survives the recycle. Before the process goes away the host closes each live ACP session, and on the next turn it reopens the persisted session id against the CLI's on-disk store — Claude and Codex through ACP `session/resume`, Copilot through `session/load` (a sequential resume rather than a live re-sync; the replayed history is discarded locally instead of being re-posted to the channel). This is the same restore path a host restart takes, and its ordinary outcome is that the model still remembers the conversation.
522
+
523
+ When a restore does fail, the run does not break: the stale session id is dropped, a fresh `session/new` starts, and the turn still answers coherently because the host re-materializes the per-channel `context.json` and re-injects the same bootstrap context on **every** turn regardless. What is lost in that case is only the CLI-native transcript. The situations that actually cause it:
524
+
525
+ - **A different machine or a different `$HOME`.** The session store is local, so continuity never crosses hosts.
526
+ - **A stale Copilot session lock.** The Copilot CLI does not reliably unlink its `inuse.<pid>.lock` on an unclean exit ([github/copilot-cli#3255](https://github.com/github/copilot-cli/issues/3255), still open), so a force-killed process can leave a lock that makes the next restore believe the session is still in use. The idle teardown mitigates this: it gives the CLI a 5-second SIGTERM grace before any SIGKILL — twenty times the grace a fatal teardown uses — precisely so the CLI has time to unlink that lock. This is an internal default, not a knob.
527
+ - **A very large Copilot transcript.** Long-running channels can hit upstream resume bugs: slow or memory-hungry resume ([#4251](https://github.com/github/copilot-cli/issues/4251)), a session that becomes unloadable once `events.jsonl` outgrows the V8 maximum string length ([#4325](https://github.com/github/copilot-cli/issues/4325)), and a resume-triggered compaction hang ([#4138](https://github.com/github/copilot-cli/issues/4138)). A Copilot resume may also reset the model back to the default ([#4397](https://github.com/github/copilot-cli/issues/4397)).
528
+ - **Our own mapping rules, which are stricter than the CLI's.** We only reuse a persisted session id when the session's working directory *and* the set of directories exposed to it still match. So an ordinary channel turn and an eligible `task_assignment` turn — which runs in its own task workspace — deliberately do not share a session, and the switch between them starts a fresh one even though the CLI could still load the old session.
529
+
530
+ If you would rather keep the adapter process resident than take any of that, set `PROVIDER_IDLE_SHUTDOWN_MINUTES=0`, raise the window, or roll the behavior back entirely with `AGENTS_HOST_INTERNAL_DISABLED_COMPATIBILITY_GATES=provider-idle-shutdown`.
510
531
 
511
532
  ## Staying up to date
512
533
 
@@ -21,6 +21,11 @@ export declare class AgentsHost {
21
21
  private readonly activeTurns;
22
22
  private readonly awaitingUserByChannel;
23
23
  private readonly collaborationOutcomeByChannel;
24
+ /**
25
+ * The session id last recorded per task, so a multi-turn task writes the
26
+ * property once rather than on every turn that reuses the same session.
27
+ */
28
+ private readonly recordedAgentSessionByTask;
24
29
  private readonly attentionSnapshotByChannel;
25
30
  private readonly missedCollaborationDiagnosticByChannel;
26
31
  private readonly progressChannelQueues;
@@ -88,11 +93,25 @@ export declare class AgentsHost {
88
93
  private armProtocolFallback;
89
94
  private runProtocolFallback;
90
95
  private handleProtocolFallbackFailure;
96
+ private ensureProtocolAnchorIncomingParts;
91
97
  private finishProtocolState;
92
98
  private cancelProtocolState;
93
99
  private confirmProtocolMessage;
100
+ private findChannelHistoryMessageById;
94
101
  private resolveAttentionStatePath;
95
102
  private buildDefaultAttentionSnapshot;
103
+ /**
104
+ * Record which provider session worked this task, as agent.session_id.
105
+ *
106
+ * agents-host writes it rather than the agent: the session id is runtime
107
+ * state the agent has no reliable view of, so asking it to report its own
108
+ * would make the association depend on the agent remembering to.
109
+ *
110
+ * Best-effort and off the turn's critical path — a task whose session id
111
+ * failed to record is still a task that ran, so a failure here must neither
112
+ * fail the turn nor delay it.
113
+ */
114
+ private recordAgentSessionOnTask;
96
115
  private buildTurnTaskThreadContext;
97
116
  private ensureAttentionSnapshotLoaded;
98
117
  private revalidateAttentionSnapshotForTurn;
@@ -14,6 +14,7 @@ import { createProvider } from './providers/create-provider.js';
14
14
  import { resolveAttentionStatePath, resolveSharedProtocolKickoffDecisionPath, resolveSharedProtocolStatusPath, } from './state-paths.js';
15
15
  import { appendVisibleMention as appendVisibleBodyMention, extractVisibleMentionIds as extractCanonicalMentionIds, } from './visible-mentions.js';
16
16
  import { waitForTaskForThread } from './task-thread-resolution.js';
17
+ import { buildHostedIncomingContentText, buildHostedTurnContentParts, extractHostedMessageAttachments, normalizeHostedMessageAttachment, } from './hosted-turn-content.js';
17
18
  const PRIVATE_STATE_ROOT_MODE = 0o700;
18
19
  const STREAM_PROGRESS_EDIT_THROTTLE_MS = 150;
19
20
  const PROTOCOL_STATE_DIRNAME = 'protocol-state';
@@ -126,7 +127,9 @@ class DefaultHostRuntime {
126
127
  deps.provider ??
127
128
  createProvider({
128
129
  provider: config.agent.provider,
130
+ borgeeBaseUrl: config.borgeeBaseUrl,
129
131
  stateRootDir: config.stateRootDir,
132
+ agentApiKey: config.agent.agentApiKey,
130
133
  resolveStableAgentId: deps.resolveStableAgentId,
131
134
  claudeCommand: config.claudeCommand,
132
135
  claudeArgs: config.claudeArgs,
@@ -135,6 +138,7 @@ class DefaultHostRuntime {
135
138
  copilotCommand: config.copilotCommand,
136
139
  copilotArgs: config.copilotArgs,
137
140
  copilotSessionTtlMinutes: config.copilotSessionTtlMinutes,
141
+ providerIdleShutdownMinutes: config.providerIdleShutdownMinutes,
138
142
  }, deps.logger, {
139
143
  compatibilityGates,
140
144
  authorizationAuditSink: this.authorizationAuditSink,
@@ -361,6 +365,11 @@ class DraftMessageController {
361
365
  return this.writeChain;
362
366
  }
363
367
  }
368
+ /**
369
+ * The registered task property agents-host writes to record which provider
370
+ * session worked a task. It matches the server's registry key.
371
+ */
372
+ const AGENT_SESSION_TASK_PROPERTY_KEY = 'agent.session_id';
364
373
  const BLOCKED_AUTHOR_REFRESH_BACKOFF_MS = 1_000;
365
374
  const MAX_QUEUED_MESSAGES_PER_CHANNEL = 8;
366
375
  const COLLABORATION_SENDS_PER_TURN = 1;
@@ -383,6 +392,11 @@ export class AgentsHost {
383
392
  activeTurns = new Set();
384
393
  awaitingUserByChannel = new Map();
385
394
  collaborationOutcomeByChannel = new Map();
395
+ /**
396
+ * The session id last recorded per task, so a multi-turn task writes the
397
+ * property once rather than on every turn that reuses the same session.
398
+ */
399
+ recordedAgentSessionByTask = new Map();
386
400
  attentionSnapshotByChannel = new Map();
387
401
  missedCollaborationDiagnosticByChannel = new Map();
388
402
  progressChannelQueues = new Map();
@@ -504,6 +518,7 @@ export class AgentsHost {
504
518
  this.collaborationOutcomeByChannel.clear();
505
519
  this.attentionSnapshotByChannel.clear();
506
520
  this.missedCollaborationDiagnosticByChannel.clear();
521
+ this.recordedAgentSessionByTask.clear();
507
522
  this.controlPlaneClosed = true;
508
523
  await this.runtime.stop();
509
524
  await Promise.allSettled([...this.activeTurns]);
@@ -547,7 +562,7 @@ export class AgentsHost {
547
562
  }
548
563
  }
549
564
  }
550
- await this.runUngatedTurn(msg, normalized.authorId, normalized.content, taskThreadContext, attentionSnapshot);
565
+ await this.runUngatedTurn(msg, normalized.authorId, normalized.content, taskThreadContext, attentionSnapshot, normalized.incomingParts);
551
566
  }
552
567
  async handleCollaborationMessage(msg) {
553
568
  const normalized = await this.normalizeMessage(msg);
@@ -596,6 +611,7 @@ export class AgentsHost {
596
611
  authorId: normalized.authorId,
597
612
  authorKind: 'unknown',
598
613
  content: normalized.content,
614
+ incomingParts: normalized.incomingParts,
599
615
  attentionSnapshot,
600
616
  taskThreadContext,
601
617
  });
@@ -614,6 +630,7 @@ export class AgentsHost {
614
630
  authorId: normalized.authorId,
615
631
  authorKind: 'human',
616
632
  content: normalized.content,
633
+ incomingParts: normalized.incomingParts,
617
634
  attentionSnapshot,
618
635
  taskThreadContext,
619
636
  wakeBlockedState,
@@ -646,6 +663,7 @@ export class AgentsHost {
646
663
  authorId: normalized.authorId,
647
664
  authorKind,
648
665
  content: normalized.content,
666
+ incomingParts: normalized.incomingParts,
649
667
  attentionSnapshot,
650
668
  taskThreadContext,
651
669
  });
@@ -740,6 +758,7 @@ export class AgentsHost {
740
758
  const executed = await this.executeTurn(msg, normalized.authorId, normalized.content, {
741
759
  kickoff: protocolCandidate,
742
760
  silent: true,
761
+ incomingParts: normalized.incomingParts,
743
762
  });
744
763
  if (executed.stale) {
745
764
  return null;
@@ -900,6 +919,7 @@ export class AgentsHost {
900
919
  authorId: normalized.authorId,
901
920
  authorKind: 'human',
902
921
  content: normalized.content,
922
+ incomingParts: normalized.incomingParts,
903
923
  });
904
924
  await this.ensureCollaborationProcessor(msg.channel_id, channelState);
905
925
  return;
@@ -912,6 +932,7 @@ export class AgentsHost {
912
932
  ownerAgentId: selfAgentId,
913
933
  anchorAuthorId: normalized.authorId,
914
934
  anchorContent: normalized.content,
935
+ anchorIncomingParts: normalized.incomingParts,
915
936
  anchorMessageId: msg.message_id ?? randomUUID(),
916
937
  participantIds: protocolRequest.participantIds,
917
938
  issuerId: protocolRequest.issuerId,
@@ -934,6 +955,7 @@ export class AgentsHost {
934
955
  authorId: normalized.authorId,
935
956
  authorKind: 'human',
936
957
  content: normalized.content,
958
+ incomingParts: normalized.incomingParts,
937
959
  });
938
960
  await this.ensureCollaborationProcessor(msg.channel_id, channelState);
939
961
  return;
@@ -1002,6 +1024,7 @@ export class AgentsHost {
1002
1024
  authorId: normalized.authorId,
1003
1025
  authorKind: 'agent',
1004
1026
  content: normalized.content,
1027
+ incomingParts: normalized.incomingParts,
1005
1028
  });
1006
1029
  await this.ensureCollaborationProcessor(msg.channel_id, channelState);
1007
1030
  }
@@ -1119,6 +1142,7 @@ export class AgentsHost {
1119
1142
  channelState.activeTurn = activeTurn;
1120
1143
  const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1121
1144
  activeTurn,
1145
+ incomingParts: message.incomingParts,
1122
1146
  incomingAuthorKind: message.authorKind,
1123
1147
  attentionSnapshot,
1124
1148
  taskThreadContext,
@@ -1222,6 +1246,7 @@ export class AgentsHost {
1222
1246
  channelState.activeTurn = activeTurn;
1223
1247
  const executed = await this.executeTurn(message.raw, message.authorId, this.buildIncomingContent(message), {
1224
1248
  activeTurn,
1249
+ incomingParts: message.incomingParts,
1225
1250
  incomingAuthorKind: message.authorKind,
1226
1251
  attentionSnapshot,
1227
1252
  taskThreadContext,
@@ -1529,59 +1554,52 @@ export class AgentsHost {
1529
1554
  }
1530
1555
  async buildResumedProtocolMessage(channelId, protocolState) {
1531
1556
  if (protocolState.currentTurnIndex === 0) {
1557
+ const anchorIncomingParts = await this.ensureProtocolAnchorIncomingParts(channelId, protocolState);
1558
+ const anchorIncomingContent = buildHostedIncomingContentText(anchorIncomingParts);
1532
1559
  return {
1533
1560
  kind: 'protocol-turn',
1534
1561
  raw: {
1535
1562
  channel_id: channelId,
1536
1563
  message_id: protocolState.anchorMessageId,
1537
1564
  user_id: protocolState.anchorAuthorId,
1538
- content: protocolState.anchorContent,
1539
- body: protocolState.anchorContent,
1565
+ content: anchorIncomingContent,
1566
+ body: anchorIncomingContent,
1567
+ attachments: extractHostedMessageAttachments(anchorIncomingParts),
1540
1568
  type: 'message',
1541
1569
  },
1542
1570
  authorId: protocolState.anchorAuthorId,
1543
1571
  authorKind: 'human',
1544
- content: protocolState.anchorContent,
1572
+ content: anchorIncomingContent,
1573
+ incomingParts: anchorIncomingParts,
1545
1574
  };
1546
1575
  }
1547
1576
  if (!protocolState.lastProtocolMessageId) {
1548
1577
  return null;
1549
1578
  }
1550
- let before;
1551
- for (;;) {
1552
- const history = await this.borgee.readChannelHistory({
1553
- channelId,
1554
- before,
1555
- limit: 200,
1579
+ const priorMessage = await this.findChannelHistoryMessageById(channelId, protocolState.lastProtocolMessageId);
1580
+ if (priorMessage) {
1581
+ const incomingParts = buildHostedTurnContentParts({
1582
+ text: priorMessage.body,
1583
+ attachments: priorMessage.attachments,
1556
1584
  });
1557
- const priorMessage = history.find((entry) => entry.id === protocolState.lastProtocolMessageId);
1558
- if (priorMessage) {
1559
- return {
1560
- kind: 'protocol-turn',
1561
- raw: {
1562
- channel_id: channelId,
1563
- message_id: priorMessage.id,
1564
- user_id: priorMessage.authorId,
1565
- content: priorMessage.body,
1566
- body: priorMessage.body,
1567
- type: 'message',
1568
- created_at: priorMessage.createdAt,
1569
- },
1570
- authorId: priorMessage.authorId,
1571
- authorKind: 'agent',
1572
- content: priorMessage.body,
1573
- };
1574
- }
1575
- if (history.length < 200) {
1576
- break;
1577
- }
1578
- const oldestCreatedAt = history.reduce((oldest, entry) => {
1579
- return oldest == null || entry.createdAt < oldest ? entry.createdAt : oldest;
1580
- }, null);
1581
- if (oldestCreatedAt == null || (before != null && oldestCreatedAt >= before)) {
1582
- break;
1583
- }
1584
- before = oldestCreatedAt;
1585
+ const incomingContent = buildHostedIncomingContentText(incomingParts);
1586
+ return {
1587
+ kind: 'protocol-turn',
1588
+ raw: {
1589
+ channel_id: channelId,
1590
+ message_id: priorMessage.id,
1591
+ user_id: priorMessage.authorId,
1592
+ content: incomingContent,
1593
+ body: incomingContent,
1594
+ attachments: priorMessage.attachments,
1595
+ type: 'message',
1596
+ created_at: priorMessage.createdAt,
1597
+ },
1598
+ authorId: priorMessage.authorId,
1599
+ authorKind: 'agent',
1600
+ content: incomingContent,
1601
+ incomingParts,
1602
+ };
1585
1603
  }
1586
1604
  return null;
1587
1605
  }
@@ -1592,6 +1610,7 @@ export class AgentsHost {
1592
1610
  ownerAgentId,
1593
1611
  anchorAuthorId: protocolState.anchorAuthorId,
1594
1612
  anchorContent: protocolState.anchorContent,
1613
+ anchorIncomingParts: protocolState.anchorIncomingParts,
1595
1614
  anchorMessageId: protocolState.anchorMessageId,
1596
1615
  participantIds: protocolState.participantIds,
1597
1616
  issuerId: protocolState.issuerId,
@@ -1670,18 +1689,36 @@ export class AgentsHost {
1670
1689
  await this.persistProtocolState(channelId, protocolState);
1671
1690
  return;
1672
1691
  }
1692
+ const anchorIncomingParts = await this.ensureProtocolAnchorIncomingParts(channelId, protocolState);
1693
+ const anchorIncomingContent = buildHostedIncomingContentText(anchorIncomingParts);
1673
1694
  const fallbackMessage = {
1674
1695
  channel_id: channelId,
1675
1696
  message_id: protocolState.anchorMessageId,
1676
1697
  user_id: protocolState.anchorAuthorId,
1677
- content: protocolState.anchorContent,
1678
- body: protocolState.anchorContent,
1698
+ content: anchorIncomingContent,
1699
+ body: anchorIncomingContent,
1700
+ attachments: extractHostedMessageAttachments(anchorIncomingParts),
1679
1701
  type: 'message',
1680
1702
  };
1681
1703
  this.cancelProtocolState(channelId, channelState);
1682
1704
  const taskThreadContext = await this.buildTurnTaskThreadContext(fallbackMessage, channelId);
1683
1705
  const attentionSnapshot = this.getAttentionSnapshotForTurn(channelId, taskThreadContext);
1684
- await this.runUngatedTurn(fallbackMessage, protocolState.anchorAuthorId, protocolState.anchorContent, taskThreadContext, attentionSnapshot);
1706
+ await this.runUngatedTurn(fallbackMessage, protocolState.anchorAuthorId, anchorIncomingContent, taskThreadContext, attentionSnapshot, anchorIncomingParts);
1707
+ }
1708
+ async ensureProtocolAnchorIncomingParts(channelId, protocolState) {
1709
+ if (protocolState.anchorIncomingParts) {
1710
+ return protocolState.anchorIncomingParts;
1711
+ }
1712
+ const anchorMessage = await this.findChannelHistoryMessageById(channelId, protocolState.anchorMessageId);
1713
+ if (!anchorMessage) {
1714
+ return buildHostedTurnContentParts({ text: protocolState.anchorContent });
1715
+ }
1716
+ const anchorIncomingParts = buildHostedTurnContentParts({
1717
+ text: protocolState.anchorContent,
1718
+ attachments: anchorMessage.attachments,
1719
+ });
1720
+ protocolState.anchorIncomingParts = anchorIncomingParts;
1721
+ return anchorIncomingParts;
1685
1722
  }
1686
1723
  finishProtocolState(channelId, channelState, protocolState, lastMessageId) {
1687
1724
  protocolState.active = false;
@@ -1721,6 +1758,36 @@ export class AgentsHost {
1721
1758
  }
1722
1759
  return matched;
1723
1760
  }
1761
+ async findChannelHistoryMessageById(channelId, messageId) {
1762
+ let before;
1763
+ for (;;) {
1764
+ const history = await this.borgee.readChannelHistory({
1765
+ channelId,
1766
+ before,
1767
+ limit: 200,
1768
+ });
1769
+ const matched = history.find((entry) => entry.id === messageId);
1770
+ if (matched) {
1771
+ return {
1772
+ ...matched,
1773
+ ...(matched.attachments
1774
+ ? { attachments: matched.attachments.map(normalizeHostedMessageAttachment) }
1775
+ : {}),
1776
+ };
1777
+ }
1778
+ if (history.length < 200) {
1779
+ break;
1780
+ }
1781
+ const oldestCreatedAt = history.reduce((oldest, entry) => {
1782
+ return oldest == null || entry.createdAt < oldest ? entry.createdAt : oldest;
1783
+ }, null);
1784
+ if (oldestCreatedAt == null || (before != null && oldestCreatedAt >= before)) {
1785
+ break;
1786
+ }
1787
+ before = oldestCreatedAt;
1788
+ }
1789
+ return null;
1790
+ }
1724
1791
  resolveAttentionStatePath(channelId) {
1725
1792
  return resolveAttentionStatePath(this.config.stateRootDir, channelId);
1726
1793
  }
@@ -1730,6 +1797,41 @@ export class AgentsHost {
1730
1797
  turnExecutionId: options?.turnExecutionId,
1731
1798
  });
1732
1799
  }
1800
+ /**
1801
+ * Record which provider session worked this task, as agent.session_id.
1802
+ *
1803
+ * agents-host writes it rather than the agent: the session id is runtime
1804
+ * state the agent has no reliable view of, so asking it to report its own
1805
+ * would make the association depend on the agent remembering to.
1806
+ *
1807
+ * Best-effort and off the turn's critical path — a task whose session id
1808
+ * failed to record is still a task that ran, so a failure here must neither
1809
+ * fail the turn nor delay it.
1810
+ */
1811
+ async recordAgentSessionOnTask(channelId, taskThreadContext, sessionId) {
1812
+ const taskId = taskThreadContext?.state === 'active' ? taskThreadContext.currentTaskId : undefined;
1813
+ if (!taskId || !sessionId) {
1814
+ return;
1815
+ }
1816
+ if (this.recordedAgentSessionByTask.get(taskId) === sessionId) {
1817
+ return;
1818
+ }
1819
+ try {
1820
+ await this.borgee.setTaskProperty({
1821
+ taskId,
1822
+ key: AGENT_SESSION_TASK_PROPERTY_KEY,
1823
+ value: sessionId,
1824
+ });
1825
+ this.recordedAgentSessionByTask.set(taskId, sessionId);
1826
+ }
1827
+ catch (error) {
1828
+ this.logger.debugError('best-effort agent session property write failed', {
1829
+ channelId,
1830
+ taskId,
1831
+ error,
1832
+ });
1833
+ }
1834
+ }
1733
1835
  async buildTurnTaskThreadContext(msg, channelId) {
1734
1836
  if (msg.message_type === 'task_assignment') {
1735
1837
  const currentTaskId = extractTaskIdFromTaskAssignmentContent(String(msg.content ?? msg.body ?? ''));
@@ -1990,10 +2092,11 @@ export class AgentsHost {
1990
2092
  reason: 'wake-suppressed-attention-policy',
1991
2093
  });
1992
2094
  }
1993
- async runUngatedTurn(msg, authorId, content, taskThreadContext, attentionSnapshot) {
2095
+ async runUngatedTurn(msg, authorId, content, taskThreadContext, attentionSnapshot, incomingParts) {
1994
2096
  const executed = await this.executeTurn(msg, authorId, content, {
1995
2097
  attentionSnapshot,
1996
2098
  taskThreadContext,
2099
+ incomingParts,
1997
2100
  });
1998
2101
  if (executed.completed && executed.reply) {
1999
2102
  this.updateAwaitingUserState(msg.channel_id, executed.reply.awaitingUser);
@@ -2086,6 +2189,11 @@ export class AgentsHost {
2086
2189
  : undefined;
2087
2190
  const missedCollaborationDiagnostic = this.resolveMissedCollaborationDiagnosticForTurn(channelId);
2088
2191
  const collaborationCapabilities = this.resolveCollaborationCapabilityDeclarationForTurn();
2192
+ const incomingParts = options?.incomingParts
2193
+ ?? buildHostedTurnContentParts({
2194
+ text: String(msg.content ?? msg.body ?? content),
2195
+ attachments: msg.attachments,
2196
+ });
2089
2197
  try {
2090
2198
  reply = await this.provider.generateReply({
2091
2199
  agentName: this.config.agent.agentName,
@@ -2093,6 +2201,7 @@ export class AgentsHost {
2093
2201
  channelId,
2094
2202
  incomingAuthorId: authorId,
2095
2203
  incomingContent: content,
2204
+ incomingParts,
2096
2205
  incomingEventKind: msg.type,
2097
2206
  incomingMessageType: msg.message_type,
2098
2207
  collaboration: turnExecutionId || options?.kickoff
@@ -2126,6 +2235,11 @@ export class AgentsHost {
2126
2235
  : undefined);
2127
2236
  publicReplyText = resolvePublicReplyText(reply);
2128
2237
  visibleReplyBody = this.resolveVisibleTurnBody(activeTurn, reply, publicReplyText);
2238
+ // NOT awaited: nothing downstream reads the result, and awaiting it here
2239
+ // widens the window before the staleness check below, where a slow write
2240
+ // can get an already-finished reply discarded. The method swallows its own
2241
+ // errors, so there is nothing for a caller to handle.
2242
+ void this.recordAgentSessionOnTask(channelId, taskThreadContext, reply.sessionId);
2129
2243
  const appliedAttentionSnapshot = this.applyAttentionUpdate(channelId, attentionSnapshot, reply.controlMalformed
2130
2244
  ? undefined
2131
2245
  : (reply.control && 'attentionUpdate' in reply.control
@@ -2631,17 +2745,22 @@ export class AgentsHost {
2631
2745
  });
2632
2746
  return null;
2633
2747
  }
2634
- const content = String(msg.content ?? msg.body ?? '').trim();
2635
- if (!content) {
2748
+ const incomingParts = buildHostedTurnContentParts({
2749
+ text: String(msg.content ?? msg.body ?? ''),
2750
+ attachments: msg.attachments,
2751
+ });
2752
+ if (incomingParts.length === 0) {
2636
2753
  this.logger.debug('ignored empty message', {
2637
2754
  channelId: msg.channel_id,
2638
2755
  messageId: msg.message_id,
2639
2756
  });
2640
2757
  return null;
2641
2758
  }
2759
+ const content = buildHostedIncomingContentText(incomingParts);
2642
2760
  return {
2643
2761
  authorId,
2644
2762
  content,
2763
+ incomingParts,
2645
2764
  };
2646
2765
  }
2647
2766
  // Only a task assigned to this agent drives a task-assignment turn: it keeps the message a
@@ -18,4 +18,13 @@ export interface ChatControlPlane {
18
18
  taskId: string;
19
19
  }): Promise<Task>;
20
20
  updateTask(input: UpdateTaskInput): Promise<Task>;
21
+ setTaskProperty(input: {
22
+ taskId: string;
23
+ key: string;
24
+ value: string;
25
+ }): Promise<Task>;
26
+ deleteTaskProperty(input: {
27
+ taskId: string;
28
+ key: string;
29
+ }): Promise<Task>;
21
30
  }
@@ -1,7 +1,7 @@
1
1
  import { type BorgeePluginClient, type BorgeePluginOptions, type InboundMessageEvent } from '../plugin-sdk.js';
2
2
  import type { ChannelSummary, ChannelHistoryEntry, ChannelMessageEvent, CreateTaskInput, DirectoryUser, MeResponseUser, PostMessageInput, PostedMessage, ReadChannelHistoryInput, Task, UpdateTaskInput } from '../types.js';
3
3
  import type { ChatControlPlane } from './chat-control-plane.js';
4
- type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'readHistory' | 'sendMessage' | 'startTyping' | 'updateTask'>;
4
+ type PluginClientLike = Pick<BorgeePluginClient, 'agentId' | 'close' | 'connect' | 'createTask' | 'deleteMessage' | 'editMessage' | 'getMe' | 'getTask' | 'listChannels' | 'listTasks' | 'listUsers' | 'on' | 'readHistory' | 'sendMessage' | 'startTyping' | 'updateTask' | 'setTaskProperty' | 'deleteTaskProperty'>;
5
5
  type PluginClientFactory = (options: BorgeePluginOptions) => PluginClientLike;
6
6
  type SdkChatControlPlaneOptions = Pick<BorgeePluginOptions, 'cursorStore' | 'pluginId'>;
7
7
  /**
@@ -34,6 +34,15 @@ export declare class SdkChatControlPlane implements ChatControlPlane {
34
34
  taskId: string;
35
35
  }): Promise<Task>;
36
36
  updateTask(input: UpdateTaskInput): Promise<Task>;
37
+ setTaskProperty(input: {
38
+ taskId: string;
39
+ key: string;
40
+ value: string;
41
+ }): Promise<Task>;
42
+ deleteTaskProperty(input: {
43
+ taskId: string;
44
+ key: string;
45
+ }): Promise<Task>;
37
46
  }
38
47
  export declare function mapInboundToChannelMessage(event: InboundMessageEvent): ChannelMessageEvent;
39
48
  export {};
@@ -1,4 +1,5 @@
1
1
  import { createBorgeePlugin, } from '../plugin-sdk.js';
2
+ import { normalizeHostedMessageAttachment } from '../hosted-turn-content.js';
2
3
  /**
3
4
  * Thin adapter over `@borgee/plugin-sdk` (the same BPP/`/ws/plugin` SDK used by
4
5
  * the OpenClaw plugin) that implements the minimal `ChatControlPlane` surface
@@ -97,6 +98,7 @@ export class SdkChatControlPlane {
97
98
  authorId: message.authorId,
98
99
  body: message.body,
99
100
  contentType: message.contentType,
101
+ attachments: message.attachments?.map(normalizeHostedMessageAttachment),
100
102
  type: message.type,
101
103
  replyToId: message.replyToId,
102
104
  createdAt: message.createdAt,
@@ -123,6 +125,12 @@ export class SdkChatControlPlane {
123
125
  async updateTask(input) {
124
126
  return this.client.updateTask(input);
125
127
  }
128
+ async setTaskProperty(input) {
129
+ return this.client.setTaskProperty(input);
130
+ }
131
+ async deleteTaskProperty(input) {
132
+ return this.client.deleteTaskProperty(input);
133
+ }
126
134
  }
127
135
  export function mapInboundToChannelMessage(event) {
128
136
  return {
@@ -136,6 +144,7 @@ export function mapInboundToChannelMessage(event) {
136
144
  content: event.message?.body,
137
145
  body: event.message?.body,
138
146
  content_type: event.message?.contentType,
147
+ attachments: event.message?.attachments?.map(normalizeHostedMessageAttachment),
139
148
  created_at: event.createdAt,
140
149
  };
141
150
  }