agents-can-communicate 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +76 -138
  2. package/SECURITY.md +31 -0
  3. package/bin/acc-hook.mjs +96 -12
  4. package/bin/acc-mcp.mjs +6 -2
  5. package/bin/acc.mjs +6 -1
  6. package/docs/ADAPTER_AUTHORING.md +172 -0
  7. package/docs/ARCHITECTURE.md +131 -0
  8. package/docs/CAPABILITIES.md +105 -197
  9. package/docs/CLI.md +157 -0
  10. package/docs/CONCEPTS.md +134 -0
  11. package/docs/CONFIGURATION.md +143 -0
  12. package/docs/DESIGN_DECISIONS.md +89 -0
  13. package/docs/GETTING_STARTED.md +145 -0
  14. package/docs/GLOSSARY.md +26 -0
  15. package/docs/MCP.md +94 -0
  16. package/docs/PROTOCOL.md +200 -0
  17. package/docs/RELEASING.md +109 -0
  18. package/docs/SECURITY_MODEL.md +131 -0
  19. package/docs/TROUBLESHOOTING.md +102 -0
  20. package/docs/WHY_ACC.md +61 -0
  21. package/docs/index.md +42 -0
  22. package/node_modules/@agents-can-communicate/adapter-claude-code/certification.json +78 -0
  23. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse-Edit.json +19 -0
  24. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/PreToolUse.json +17 -0
  25. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionEnd.json +8 -0
  26. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/SessionStart.json +7 -0
  27. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/UserPromptSubmit.json +9 -0
  28. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/certification-provenance.json +77 -0
  29. package/node_modules/@agents-can-communicate/adapter-claude-code/fixtures/delivery/claude-code-2.1.252.json +19 -0
  30. package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +9 -1
  31. package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -160
  32. package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +15 -5
  33. package/node_modules/@agents-can-communicate/adapter-codex/certification.json +117 -0
  34. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/PreToolUse.json +14 -0
  35. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionEnd.json +7 -0
  36. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/SessionStart.json +9 -0
  37. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/UserPromptSubmit.json +10 -0
  38. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/certification-provenance.json +66 -0
  39. package/node_modules/@agents-can-communicate/adapter-codex/fixtures/delivery/codex-cli-0.152.0.json +19 -0
  40. package/node_modules/@agents-can-communicate/adapter-codex/package.json +8 -1
  41. package/node_modules/@agents-can-communicate/adapter-codex/plugin/.codex-plugin/plugin.json +1 -1
  42. package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -160
  43. package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +21 -12
  44. package/node_modules/@agents-can-communicate/adapter-gemini-cli/certification.json +52 -0
  45. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/gemini-extension.json +1 -1
  46. package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +80 -160
  47. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeAgent.json +8 -0
  48. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool-shell.json +12 -0
  49. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/BeforeTool.json +12 -0
  50. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionEnd.json +8 -0
  51. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/SessionStart.json +8 -0
  52. package/node_modules/@agents-can-communicate/adapter-gemini-cli/fixtures/certification-provenance.json +66 -0
  53. package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +8 -1
  54. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +10 -4
  55. package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/install.mjs +4 -2
  56. package/node_modules/@agents-can-communicate/adapter-grok/certification.json +3 -0
  57. package/node_modules/@agents-can-communicate/adapter-grok/package.json +14 -0
  58. package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
  59. package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +152 -0
  60. package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
  61. package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
  62. package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
  63. package/node_modules/@agents-can-communicate/adapter-kimi/certification.json +52 -0
  64. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Bash.json +12 -0
  65. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/PreToolUse-Write.json +12 -0
  66. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionHeartbeat.json +7 -0
  67. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/SessionStart.json +9 -0
  68. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/UserPromptSubmit.json +8 -0
  69. package/node_modules/@agents-can-communicate/adapter-kimi/fixtures/certification-provenance.json +66 -0
  70. package/node_modules/@agents-can-communicate/adapter-kimi/package.json +8 -1
  71. package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -160
  72. package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +10 -4
  73. package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
  74. package/node_modules/@agents-can-communicate/adapter-sdk/src/capabilities.mjs +34 -18
  75. package/node_modules/@agents-can-communicate/adapter-sdk/src/certification.mjs +158 -0
  76. package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +139 -224
  77. package/node_modules/@agents-can-communicate/adapter-sdk/src/hook-shim.mjs +7 -1
  78. package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +2 -1
  79. package/node_modules/@agents-can-communicate/adapter-sdk/src/session-binding.mjs +13 -4
  80. package/node_modules/@agents-can-communicate/cli/package.json +1 -1
  81. package/node_modules/@agents-can-communicate/cli/src/args.mjs +13 -29
  82. package/node_modules/@agents-can-communicate/cli/src/doctor-command.mjs +3 -0
  83. package/node_modules/@agents-can-communicate/cli/src/help.mjs +5 -6
  84. package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +12 -3
  85. package/node_modules/@agents-can-communicate/cli/src/main.mjs +109 -109
  86. package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
  87. package/node_modules/@agents-can-communicate/core/package.json +1 -1
  88. package/node_modules/@agents-can-communicate/core/src/attention.mjs +106 -0
  89. package/node_modules/@agents-can-communicate/core/src/conversations.mjs +276 -0
  90. package/node_modules/@agents-can-communicate/core/src/delivery-bindings.mjs +81 -0
  91. package/node_modules/@agents-can-communicate/core/src/finish-retries.mjs +97 -0
  92. package/node_modules/@agents-can-communicate/core/src/inbox.mjs +118 -0
  93. package/node_modules/@agents-can-communicate/core/src/index.mjs +3 -2
  94. package/node_modules/@agents-can-communicate/core/src/intents.mjs +0 -1
  95. package/node_modules/@agents-can-communicate/core/src/ports.mjs +3 -2
  96. package/node_modules/@agents-can-communicate/core/src/receipts.mjs +109 -0
  97. package/node_modules/@agents-can-communicate/core/src/service.mjs +14 -10
  98. package/node_modules/@agents-can-communicate/core/src/sessions.mjs +70 -20
  99. package/node_modules/@agents-can-communicate/core/src/status.mjs +11 -9
  100. package/node_modules/@agents-can-communicate/core/src/sync.mjs +3 -258
  101. package/node_modules/@agents-can-communicate/delivery-router/package.json +12 -0
  102. package/node_modules/@agents-can-communicate/delivery-router/src/index.mjs +1 -0
  103. package/node_modules/@agents-can-communicate/delivery-router/src/router.mjs +117 -0
  104. package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
  105. package/node_modules/@agents-can-communicate/hook-runner/src/client-version.mjs +20 -0
  106. package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +156 -60
  107. package/node_modules/@agents-can-communicate/installer/package.json +1 -1
  108. package/node_modules/@agents-can-communicate/installer/src/apply.mjs +23 -7
  109. package/node_modules/@agents-can-communicate/installer/src/detect.mjs +20 -5
  110. package/node_modules/@agents-can-communicate/installer/src/index.mjs +3 -2
  111. package/node_modules/@agents-can-communicate/installer/src/ownership.mjs +108 -12
  112. package/node_modules/@agents-can-communicate/installer/src/plan.mjs +19 -2
  113. package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
  114. package/node_modules/@agents-can-communicate/mcp-server/src/input-validator.mjs +79 -0
  115. package/node_modules/@agents-can-communicate/mcp-server/src/resources.mjs +23 -28
  116. package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +109 -71
  117. package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +74 -93
  118. package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
  119. package/node_modules/@agents-can-communicate/protocol/src/config.mjs +1 -1
  120. package/node_modules/@agents-can-communicate/protocol/src/conversations.mjs +64 -0
  121. package/node_modules/@agents-can-communicate/protocol/src/index.mjs +4 -1
  122. package/node_modules/@agents-can-communicate/protocol/src/schema.mjs +49 -90
  123. package/node_modules/@agents-can-communicate/protocol/src/states.mjs +13 -40
  124. package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
  125. package/node_modules/@agents-can-communicate/storage-filesystem/src/active-journal.mjs +230 -0
  126. package/node_modules/@agents-can-communicate/storage-filesystem/src/atomic-json.mjs +77 -28
  127. package/node_modules/@agents-can-communicate/storage-filesystem/src/identity.mjs +1 -1
  128. package/node_modules/@agents-can-communicate/storage-filesystem/src/journal.mjs +83 -35
  129. package/node_modules/@agents-can-communicate/storage-filesystem/src/retention.mjs +112 -0
  130. package/node_modules/@agents-can-communicate/storage-filesystem/src/safe-file.mjs +18 -8
  131. package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +86 -28
  132. package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +121 -27
  133. package/package.json +22 -1
  134. package/node_modules/@agents-can-communicate/core/src/communication.mjs +0 -334
  135. package/node_modules/@agents-can-communicate/core/src/notify.mjs +0 -95
  136. package/node_modules/@agents-can-communicate/core/src/tasks.mjs +0 -244
  137. package/node_modules/@agents-can-communicate/core/src/workstreams.mjs +0 -109
@@ -1,334 +0,0 @@
1
- import { AccError, EXIT, SCHEMA_VERSION, advanceDelivery, createId, validateRecord }
2
- from "@agents-can-communicate/protocol";
3
-
4
- import { ensureMaterialised } from "./materialisation.mjs";
5
- import { assertKnownParticipants } from "./participants.mjs";
6
- import { writeTask } from "./tasks.mjs";
7
-
8
- const receiptId = (messageId, recipient) => `${messageId}--${recipient}`;
9
-
10
- // Peer content is data, never authority. Messages carry attribution and a
11
- // typed intent; nothing in a body can change policy or promote a proposal.
12
- export function createCommunicationService(ports, sessions, claims) {
13
- const { store, clock, ids } = ports;
14
-
15
- async function requireOpenSession(input, action) {
16
- const existing = await sessions.locateSession(input.sessionId, input.workspaceId);
17
- if (existing === null || existing.record.state !== "open"
18
- || existing.record.generation !== input.generation) {
19
- throw new AccError(EXIT.CONFLICT, `cannot ${action} from this session generation`,
20
- { sessionId: input.sessionId });
21
- }
22
- return existing.record;
23
- }
24
-
25
- async function sendMessage(input) {
26
- const session = await requireOpenSession(input, "send a message");
27
- const workspaceId = session.workspaceId;
28
- await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
29
- reason: "durable_object" });
30
- const now = clock.now();
31
- const messageId = createId("message");
32
- const recipients = input.toParticipantIds ?? [];
33
- if (recipients.length === 0) {
34
- throw new AccError(EXIT.USAGE, "a message needs at least one recipient", { messageId });
35
- }
36
- const record = validateRecord("message", {
37
- schemaVersion: SCHEMA_VERSION,
38
- messageId,
39
- workspaceId,
40
- fromSessionId: session.sessionId,
41
- fromParticipantId: session.participantId,
42
- toParticipantIds: recipients,
43
- type: input.type,
44
- subject: input.subject,
45
- body: input.body,
46
- priority: input.priority ?? "normal",
47
- workstreamId: input.workstreamId ?? null,
48
- taskId: input.taskId ?? null,
49
- inReplyTo: input.inReplyTo ?? null,
50
- requiresAck: input.requiresAck === true,
51
- artifacts: input.artifacts ?? [],
52
- sentAt: now,
53
- });
54
- // After the record is validated, so a body carrying control characters is
55
- // refused for what it is rather than for who it was addressed to.
56
- await assertKnownParticipants(store, workspaceId, recipients);
57
-
58
- await store.transaction(async tx => {
59
- tx.put("message", messageId, record);
60
- // One receipt per recipient: a receipt from one recipient can never move
61
- // another recipient's state.
62
- for (const recipient of recipients) {
63
- tx.put("receipt", receiptId(messageId, recipient), validateRecord("receipt", {
64
- schemaVersion: SCHEMA_VERSION,
65
- messageId,
66
- workspaceId,
67
- recipientParticipantId: recipient,
68
- state: "queued",
69
- updatedAt: now,
70
- }));
71
- }
72
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
73
- actorSessionId: session.sessionId, type: "message.sent", occurredAt: now,
74
- payload: { messageId, type: record.type, recipients } });
75
- }, { kinds: ["message", "receipt"] });
76
- return record;
77
- }
78
-
79
- async function markDelivery(input) {
80
- const session = await requireOpenSession(input, "update delivery");
81
- // Your own receipt, unless you say otherwise and mean it. A session
82
- // advancing another participant's receipt would be reporting that someone
83
- // else had read something.
84
- const recipient = input.recipientParticipantId ?? session.participantId;
85
- if (recipient !== session.participantId) {
86
- throw new AccError(EXIT.CONFLICT, "a session can only mark its own receipt",
87
- { recipient, participantId: session.participantId });
88
- }
89
- const now = clock.now();
90
- let record = null;
91
- await store.transaction(async tx => {
92
- const id = receiptId(input.messageId, recipient);
93
- const existing = tx.get("receipt", id);
94
- if (existing === null) {
95
- throw new AccError(EXIT.DATA, "no receipt exists for that recipient", { id });
96
- }
97
- // Monotonic by construction: the protocol state machine refuses to move
98
- // backwards, so an acknowledgement can never be downgraded to seen.
99
- record = { ...existing, state: advanceDelivery(existing.state, input.state),
100
- updatedAt: now };
101
- tx.put("receipt", id, record, tx.generationOf("receipt", id));
102
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
103
- workspaceId: existing.workspaceId, actorSessionId: session.sessionId,
104
- type: `message.${record.state}`, occurredAt: now,
105
- payload: { messageId: input.messageId,
106
- recipientParticipantId: recipient } });
107
- }, { kinds: ["receipt"] });
108
- return record;
109
- }
110
-
111
- async function recordDecision(input) {
112
- const session = await requireOpenSession(input, "record a decision");
113
- const workspaceId = session.workspaceId;
114
- await ensureMaterialised(ports, { workspaceId, reason: "durable_object" });
115
- // A peer proposal never becomes a human-authority decision on its own.
116
- if (input.authority === "human" && input.humanConfirmed !== true) {
117
- throw new AccError(EXIT.CONFLICT,
118
- "human authority requires an explicit human confirmation", { authority: "human" });
119
- }
120
- const now = clock.now();
121
- const decisionId = createId("decision");
122
- const record = validateRecord("decision", {
123
- schemaVersion: SCHEMA_VERSION,
124
- decisionId,
125
- workspaceId,
126
- workstreamId: input.workstreamId ?? null,
127
- title: input.title,
128
- outcome: input.outcome,
129
- authority: input.authority,
130
- decidedBy: input.decidedBy ?? [session.participantId],
131
- evidence: input.evidence ?? [],
132
- supersedes: input.supersedes ?? null,
133
- decidedAt: now,
134
- });
135
- await store.transaction(async tx => {
136
- if (record.supersedes !== null && tx.get("decision", record.supersedes) === null) {
137
- throw new AccError(EXIT.DATA, "the superseded decision does not exist",
138
- { supersedes: record.supersedes });
139
- }
140
- tx.put("decision", decisionId, record);
141
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
142
- actorSessionId: session.sessionId, type: "decision.recorded", occurredAt: now,
143
- payload: { decisionId, authority: record.authority } });
144
- }, { kinds: ["decision"] });
145
- return record;
146
- }
147
-
148
- /**
149
- * Produce a handoff and release what this session owned. The semantic summary
150
- * is written while the model is still active; session end only closes
151
- * lifecycle ownership.
152
- */
153
- /**
154
- * What the successor needs, in the order they need it: what this was for, how
155
- * far it got, what is left, and what is in the way.
156
- */
157
- function describeHandoff(record) {
158
- const section = (label, items) => (items.length === 0
159
- ? [] : [`${label}:`, ...items.map(item => `- ${item}`)]);
160
- return [
161
- // The goal is the subject; repeating it here costs a line of everyone's
162
- // turn to say the same thing twice.
163
- record.status,
164
- ...section("done", record.completed),
165
- // Not "left": the status line above already uses that word for how far
166
- // this got, and the two meanings sat one line apart.
167
- ...section("still to do", record.remaining),
168
- ...section("in the way", record.blockers),
169
- ...section("released", record.claimsToRelease),
170
- ].join("\n");
171
- }
172
-
173
- async function finishSession(input) {
174
- const session = await requireOpenSession(input, "finish");
175
- const workspaceId = session.workspaceId;
176
- await ensureMaterialised(ports, { workspaceId, reason: "durable_object" });
177
- const now = clock.now();
178
- const handoffId = createId("handoff");
179
- const owned = (await store.snapshot(workspaceId, { kinds: ["claim"] })).claims
180
- .filter(claim => claim.ownerSessionId === session.sessionId);
181
- const successor = input.toParticipantId ?? null;
182
- // The same rule as addressing anything else: handing your work to a name
183
- // nobody has is a typo, and the summary is the last thing this session will
184
- // ever say.
185
- if (successor !== null) await assertKnownParticipants(store, workspaceId, [successor]);
186
- const record = validateRecord("handoff", {
187
- schemaVersion: SCHEMA_VERSION,
188
- handoffId,
189
- workspaceId,
190
- fromSessionId: session.sessionId,
191
- toParticipantId: successor,
192
- goal: input.goal,
193
- status: input.status ?? "partial",
194
- completed: input.completed ?? [],
195
- remaining: input.remaining ?? [],
196
- blockers: input.blockers ?? [],
197
- claimsToRelease: owned.map(claim => claim.resource),
198
- verification: input.verification ?? [],
199
- artifacts: input.artifacts ?? [],
200
- createdAt: now,
201
- });
202
-
203
- await store.transaction(async tx => {
204
- tx.put("handoff", handoffId, record);
205
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
206
- actorSessionId: session.sessionId, type: "handoff.created", occurredAt: now,
207
- payload: { handoffId, released: record.claimsToRelease } });
208
- }, { kinds: ["handoff"] });
209
-
210
- // A handoff nobody is told about is a note to the store. Written, durable,
211
- // and reaching the agent it names through nothing at all: not their turn,
212
- // not their attention, not `acc status` - only a full snapshot they would
213
- // have to scan for their own name. The message type for this has been in the
214
- // schema all along, and this is the half that was never built.
215
- if (successor !== null) {
216
- await sendMessage({
217
- sessionId: session.sessionId, generation: input.generation,
218
- toParticipantIds: [successor], type: "handoff",
219
- subject: `handing over: ${record.goal}`,
220
- body: describeHandoff(record),
221
- descriptor: input.descriptor,
222
- });
223
- }
224
-
225
- for (const claim of owned) {
226
- await claims.releaseClaim({ claimId: claim.claimId, sessionId: session.sessionId,
227
- generation: session.generation, workspaceId });
228
- }
229
- return record;
230
- }
231
-
232
- /**
233
- * Messages addressed to this participant that nothing has shown a model yet.
234
- *
235
- * `queued` is where `sendMessage` leaves a receipt. Anything further along has
236
- * already been put in front of the recipient, and delivery states only move
237
- * forward - so re-injecting would misreport what happened rather than repeat
238
- * harmlessly.
239
- *
240
- * A session never receives its own message back. Another session of the same
241
- * participant does, because it is a different reader.
242
- */
243
- async function pendingMessages(input = {}) {
244
- const participantId = input.participantId;
245
- if (typeof participantId !== "string" || participantId === "") return [];
246
- const workspaceId = input.workspaceId ?? store.workspaceId;
247
- // Messages and receipts are the two unbounded kinds and this read needs
248
- // both. Nothing else, though, and it runs in front of every turn.
249
- const snapshot = await store.snapshot(workspaceId,
250
- { kinds: ["message", "receipt"] });
251
- const waiting = new Set((snapshot.receipts ?? [])
252
- .filter(receipt => receipt.recipientParticipantId === participantId
253
- && (receipt.state === "queued" || receipt.state === "recorded"))
254
- .map(receipt => receipt.messageId));
255
- return (snapshot.messages ?? [])
256
- .filter(message => waiting.has(message.messageId)
257
- && message.fromSessionId !== input.exceptSessionId)
258
- // Oldest first, and the id breaks ties so two messages sent in the same
259
- // millisecond still project in a stable order.
260
- .sort((left, right) => left.sentAt.localeCompare(right.sentAt)
261
- || left.messageId.localeCompare(right.messageId));
262
- }
263
-
264
- /**
265
- * Ask another agent to do something.
266
- *
267
- * One write, because the two halves are useless apart. A task addressed to
268
- * someone who was never told about it is work nobody knows exists, and a
269
- * message describing work that was never recorded is a request with nothing
270
- * to point at.
271
- *
272
- * The recipient learns about it twice over, and both paths already existed:
273
- * the task raises a `task_unblocked` attention item for the participant it
274
- * names, and the message reaches their turn as quoted peer text.
275
- */
276
- async function requestWork(input) {
277
- const session = await requireOpenSession(input, "request work");
278
- const workspaceId = session.workspaceId;
279
- const recipient = input.toParticipantId;
280
- if (typeof recipient !== "string" || recipient === "") {
281
- throw new AccError(EXIT.USAGE, "a request needs a recipient participant");
282
- }
283
- await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
284
- reason: "durable_object" });
285
- await assertKnownParticipants(store, workspaceId, [recipient]);
286
- const now = clock.now();
287
- const messageId = createId("message");
288
- let task = null;
289
- let message = null;
290
-
291
- await store.transaction(async tx => {
292
- task = writeTask(tx, { session, workspaceId, now, ids,
293
- input: { ...input, assigneeParticipantId: recipient,
294
- requestedByParticipantId: session.participantId } });
295
- message = validateRecord("message", {
296
- schemaVersion: SCHEMA_VERSION,
297
- messageId,
298
- workspaceId,
299
- fromSessionId: session.sessionId,
300
- fromParticipantId: session.participantId,
301
- toParticipantIds: [recipient],
302
- type: "work_request",
303
- subject: input.title,
304
- body: input.detail ?? input.title,
305
- priority: input.priority ?? "normal",
306
- workstreamId: task.workstreamId,
307
- taskId: task.taskId,
308
- inReplyTo: input.inReplyTo ?? null,
309
- // A request is a question, so the sender is entitled to know whether it
310
- // was taken up. Silence and refusal are different answers.
311
- requiresAck: true,
312
- artifacts: input.artifacts ?? [],
313
- sentAt: now,
314
- });
315
- tx.put("message", messageId, message);
316
- tx.put("receipt", receiptId(messageId, recipient), validateRecord("receipt", {
317
- schemaVersion: SCHEMA_VERSION,
318
- messageId,
319
- workspaceId,
320
- recipientParticipantId: recipient,
321
- state: "queued",
322
- updatedAt: now,
323
- }));
324
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
325
- actorSessionId: session.sessionId, type: "work.requested", occurredAt: now,
326
- payload: { taskId: task.taskId, messageId, recipient } });
327
- // `writeTask` runs on this handle, so what it reads is read here.
328
- }, { kinds: ["message", "receipt", "session", "task", "workstream"] });
329
- return { task, message };
330
- }
331
-
332
- return { sendMessage, markDelivery, pendingMessages, requestWork, recordDecision,
333
- finishSession };
334
- }
@@ -1,95 +0,0 @@
1
- import { SCHEMA_VERSION, advanceDelivery, createId, validateRecord }
2
- from "@agents-can-communicate/protocol";
3
-
4
- const receiptId = (messageId, recipient) => `${messageId}--${recipient}`;
5
-
6
- /**
7
- * Tell whoever asked for a task what became of it.
8
- *
9
- * A request is a question, so it deserves an answer: taken up, declined, or
10
- * finished. Before this the task recorded who it was for and not who was
11
- * waiting, so nothing could be said back and the asker had to poll and diff
12
- * task states to find out.
13
- *
14
- * It is a message rather than an attention rule on purpose. Messages carry
15
- * delivery state, so the answer is put in front of the asker exactly once
16
- * instead of repeating in every turn until they act on it.
17
- *
18
- * Written into the caller's transaction: an accepted task whose acceptance was
19
- * never sent is the same silence this exists to remove.
20
- */
21
- export function writeWorkResponse(tx, { task, actor, workspaceId, now, ids, outcome,
22
- reason = null }) {
23
- const asker = task.requestedByParticipantId;
24
- // Nobody asked, or the asker is doing it themselves - there is no one to tell.
25
- if (asker === null || asker === undefined || asker === actor.participantId) return null;
26
-
27
- const messageId = createId("message");
28
- // The subject already carries the outcome and the title. The body says who
29
- // and which task, so the asker can follow it up without going and looking.
30
- const head = `${outcome} by ${actor.participantId} (task ${task.taskId})`;
31
- const body = reason === null ? head : `${head}\n\n${reason}`;
32
- const record = validateRecord("message", {
33
- schemaVersion: SCHEMA_VERSION,
34
- messageId,
35
- workspaceId,
36
- fromSessionId: actor.sessionId,
37
- fromParticipantId: actor.participantId,
38
- toParticipantIds: [asker],
39
- type: "work_response",
40
- subject: `${outcome}: ${task.title}`,
41
- body,
42
- priority: "normal",
43
- workstreamId: task.workstreamId,
44
- taskId: task.taskId,
45
- inReplyTo: null,
46
- // The asker is being told, not asked. Requiring an acknowledgement here
47
- // would leave every finished request sitting in someone's attention list.
48
- requiresAck: false,
49
- artifacts: [],
50
- sentAt: now,
51
- });
52
- tx.put("message", messageId, record);
53
- tx.put("receipt", receiptId(messageId, asker), validateRecord("receipt", {
54
- schemaVersion: SCHEMA_VERSION,
55
- messageId,
56
- workspaceId,
57
- recipientParticipantId: asker,
58
- state: "queued",
59
- updatedAt: now,
60
- }));
61
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
62
- actorSessionId: actor.sessionId, type: `work.${outcome}`, occurredAt: now,
63
- payload: { taskId: task.taskId, messageId, asker } });
64
- return record;
65
- }
66
-
67
- /**
68
- * Close the request a task came from, once it has been answered.
69
- *
70
- * `acc request` marks its message as needing an acknowledgement, which raises a
71
- * `direct_request` attention item for the recipient. Finishing the work did not
72
- * clear it, and nothing else could: the operation existed in the core and was
73
- * reachable from no surface. So every completed request left a line repeating in
74
- * the doer's turn for good.
75
- *
76
- * Doing the work is the acknowledgement. An explicit `acc ack` exists for
77
- * messages that are not tied to a task.
78
- */
79
- export function closeRequestReceipt(tx, { task, actor, now, ids }) {
80
- for (const message of tx.list("message")) {
81
- if (message.taskId !== task.taskId || message.type !== "work_request") continue;
82
- if (!message.toParticipantIds.includes(actor.participantId)) continue;
83
- const id = `${message.messageId}--${actor.participantId}`;
84
- const existing = tx.get("receipt", id);
85
- if (existing === null || existing.state === "acknowledged") continue;
86
- const record = { ...existing, state: advanceDelivery(existing.state, "acknowledged"),
87
- updatedAt: now };
88
- tx.put("receipt", id, record, tx.generationOf("receipt", id));
89
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
90
- workspaceId: existing.workspaceId, actorSessionId: actor.sessionId,
91
- type: "message.acknowledged", occurredAt: now,
92
- payload: { messageId: message.messageId,
93
- recipientParticipantId: actor.participantId } });
94
- }
95
- }
@@ -1,244 +0,0 @@
1
- import { AccError, EXIT, SCHEMA_VERSION, createId, transitionTask as stepTask, validateRecord }
2
- from "@agents-can-communicate/protocol";
3
-
4
- import { ensureMaterialised } from "./materialisation.mjs";
5
- import { assertKnownParticipants } from "./participants.mjs";
6
- import { classifySessionPresence } from "./sessions.mjs";
7
- import { closeRequestReceipt, writeWorkResponse } from "./notify.mjs";
8
-
9
- // Dependency completion unblocks tasks deterministically, inside the same
10
- // transaction that completed the dependency. It must never depend on a model
11
- // remembering to re-evaluate the graph.
12
- //
13
- // Exported because a caller may supply its own task id - an adapter mirroring
14
- // an external tracker, for instance - and that is the only way a create can
15
- // close a cycle. Without an explicit id the guard would be unreachable.
16
- export function wouldCycle(tasks, taskId, dependsOn) {
17
- const byId = new Map(tasks.map(task => [task.taskId, task]));
18
- const seen = new Set();
19
- const stack = [...dependsOn];
20
- while (stack.length > 0) {
21
- const current = stack.pop();
22
- if (current === taskId) return true;
23
- if (seen.has(current)) continue;
24
- seen.add(current);
25
- stack.push(...(byId.get(current)?.dependsOn ?? []));
26
- }
27
- return false;
28
- }
29
-
30
- const blockedBy = (task, byId) => task.dependsOn
31
- .filter(id => (byId.get(id)?.state ?? "pending") !== "done");
32
-
33
- /**
34
- * Write one task inside a transaction the caller already owns.
35
- *
36
- * Separated so that `acc request` - which creates the task and tells the
37
- * recipient about it - can do both as one write. A request that produced a task
38
- * and then failed to mention it would leave work addressed to an agent that was
39
- * never told, which is worse than no request at all.
40
- */
41
- export function writeTask(tx, { input, session, workspaceId, now, ids }) {
42
- const taskId = input.taskId ?? createId("task");
43
- const existing = tx.list("task");
44
- const dependsOn = input.dependsOn ?? [];
45
- for (const dependency of dependsOn) {
46
- if (tx.get("task", dependency) === null) {
47
- throw new AccError(EXIT.DATA, "a dependency does not exist", { dependency });
48
- }
49
- }
50
- if (wouldCycle(existing, taskId, dependsOn)) {
51
- throw new AccError(EXIT.DATA, "the dependency graph would contain a cycle", { taskId });
52
- }
53
- // A workstream is optional - "finish these tests for me" should not require
54
- // inventing a project first - but a named one has to exist, or the task hangs
55
- // off nothing and nobody notices.
56
- const workstreamId = input.workstreamId ?? null;
57
- if (workstreamId !== null && tx.get("workstream", workstreamId) === null) {
58
- throw new AccError(EXIT.DATA, "the workstream does not exist", { workstreamId });
59
- }
60
- const record = validateRecord("task", {
61
- schemaVersion: SCHEMA_VERSION,
62
- taskId,
63
- workstreamId,
64
- workspaceId,
65
- title: input.title,
66
- detail: input.detail ?? null,
67
- state: blockedBy({ dependsOn }, new Map(existing.map(task => [task.taskId, task])))
68
- .length > 0 ? "blocked" : "pending",
69
- // Addressed to a participant, so the request survives that agent closing
70
- // its terminal. Whoever picks it up is recorded separately.
71
- assigneeParticipantId: input.assigneeParticipantId ?? null,
72
- assigneeSessionId: null,
73
- requestedByParticipantId: input.requestedByParticipantId ?? null,
74
- dependsOn,
75
- acceptance: input.acceptance ?? [],
76
- createdAt: now,
77
- });
78
- tx.put("task", taskId, record);
79
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"), workspaceId,
80
- actorSessionId: session.sessionId, type: "task.created", occurredAt: now,
81
- payload: { taskId, state: record.state,
82
- assigneeParticipantId: record.assigneeParticipantId } });
83
- return record;
84
- }
85
-
86
- export function createTaskService(ports, workstreams) {
87
- const { store, clock, ids, pidIsAlive } = ports;
88
-
89
- async function createTask(input) {
90
- const session = await workstreams.requireOpenSession(input, "create a task");
91
- const workspaceId = session.workspaceId;
92
- await ensureMaterialised(ports, { workspaceId, descriptor: input.descriptor,
93
- reason: "durable_object" });
94
- // The same rule as addressing a message. `acc task --assignee physcis` was
95
- // accepted and left work `pending` for a participant nobody has ever been:
96
- // invisible to every roster, raising `task_unblocked` for nobody, and not
97
- // even stalled, since nothing was waiting on it that could be told.
98
- if (input.assigneeParticipantId != null) {
99
- await assertKnownParticipants(store, workspaceId, [input.assigneeParticipantId]);
100
- }
101
- const now = clock.now();
102
- let record = null;
103
- await store.transaction(async tx => {
104
- record = writeTask(tx, { input, session, workspaceId, now, ids });
105
- // `writeTask` reads and writes on this handle, so its kinds are ours.
106
- }, { kinds: ["session", "task", "workstream"] });
107
- return record;
108
- }
109
-
110
- async function claimTask(input) {
111
- const session = await workstreams.requireOpenSession(input, "claim a task");
112
- const now = clock.now();
113
- let record = null;
114
- await store.transaction(async tx => {
115
- const existing = tx.get("task", input.taskId);
116
- if (existing === null) {
117
- throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
118
- }
119
- if (existing.assigneeSessionId !== null
120
- && existing.assigneeSessionId !== session.sessionId) {
121
- // A holder that is gone is not a holder. Closing a session hands its
122
- // work back, so this is the crash case: no session end ever arrived and
123
- // presence has decayed. `stale` alone does not release it - the same
124
- // rule claims follow, because an idle agent may be thinking rather than
125
- // dead - and taking it over needs `force`. `offline` needs no force at
126
- // all, silence-derived cases included: a session with no recorded pid,
127
- // quiet past the unknown floor, reads offline exactly like one whose
128
- // pid is confirmed dead, and either way the task is simply taken. That
129
- // is acceptable here where it would not be for a session id: the
130
- // participant guard below still refuses a different participant, and
131
- // taking a task back is reversible where replacing an id is not.
132
- const holder = tx.get("session", existing.assigneeSessionId);
133
- const presence = holder === null
134
- ? "offline"
135
- : classifySessionPresence(holder, now, pidIsAlive);
136
- if (presence === "online") {
137
- throw new AccError(EXIT.CONFLICT, "the task already has an assignee",
138
- { taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId });
139
- }
140
- if (presence === "stale" && input.force !== true) {
141
- throw new AccError(EXIT.CONFLICT,
142
- "the task is held by a session that has gone quiet; take it with force",
143
- { taskId: input.taskId, assigneeSessionId: existing.assigneeSessionId,
144
- presence });
145
- }
146
- }
147
- // Work addressed to one participant is not picked up by another. Taking
148
- // an unaddressed task is open to anyone, which is what makes a request
149
- // with no named recipient a request to the room.
150
- if (existing.assigneeParticipantId !== null
151
- && existing.assigneeParticipantId !== session.participantId) {
152
- throw new AccError(EXIT.CONFLICT, "the task is addressed to another participant",
153
- { taskId: input.taskId,
154
- assigneeParticipantId: existing.assigneeParticipantId });
155
- }
156
- record = { ...existing, assigneeSessionId: session.sessionId,
157
- assigneeParticipantId: existing.assigneeParticipantId ?? session.participantId,
158
- state: stepTask(existing.state, "in_progress") };
159
- // Whoever asked is waiting on an answer, and "someone is on it" is one.
160
- writeWorkResponse(tx, { task: record, actor: session,
161
- workspaceId: session.workspaceId, now, ids, outcome: "accepted" });
162
- tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
163
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
164
- workspaceId: session.workspaceId, actorSessionId: session.sessionId,
165
- type: "task.claimed", occurredAt: now, payload: { taskId: input.taskId } });
166
- }, { kinds: ["message", "receipt", "session", "task"] });
167
- return record;
168
- }
169
-
170
- async function transitionTask(input) {
171
- const session = await workstreams.requireOpenSession(input, "transition a task");
172
- const now = clock.now();
173
- let record = null;
174
- await store.transaction(async tx => {
175
- const existing = tx.get("task", input.taskId);
176
- if (existing === null) {
177
- throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
178
- }
179
- record = { ...existing, state: stepTask(existing.state, input.state) };
180
- tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
181
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
182
- workspaceId: session.workspaceId, actorSessionId: session.sessionId,
183
- type: "task.transitioned", occurredAt: now,
184
- payload: { taskId: input.taskId, state: record.state } });
185
-
186
- if (record.state === "done" || record.state === "review") {
187
- writeWorkResponse(tx, { task: record, actor: session,
188
- workspaceId: session.workspaceId, now, ids, outcome: record.state });
189
- }
190
- // Doing the work answers the request that asked for it.
191
- if (record.state === "done") closeRequestReceipt(tx, { task: record, actor: session, now, ids });
192
- if (record.state !== "done") return;
193
- // Unblock dependents here, in the same transaction, so the graph is
194
- // never left in a state that needs someone to notice it later.
195
- const byId = new Map(tx.list("task").map(task => [task.taskId, task]));
196
- byId.set(record.taskId, record);
197
- for (const dependent of byId.values()) {
198
- if (dependent.state !== "blocked") continue;
199
- if (blockedBy(dependent, byId).length > 0) continue;
200
- const unblocked = { ...dependent, state: "pending" };
201
- tx.put("task", dependent.taskId, unblocked,
202
- tx.generationOf("task", dependent.taskId));
203
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
204
- workspaceId: session.workspaceId, actorSessionId: session.sessionId,
205
- type: "task.unblocked", occurredAt: now, payload: { taskId: dependent.taskId } });
206
- }
207
- }, { kinds: ["message", "receipt", "task"] });
208
- return record;
209
- }
210
-
211
- /**
212
- * Refuse a request, with a reason.
213
- *
214
- * The task returns to unclaimed rather than being deleted: the work is still
215
- * wanted, it is just not this agent's. Leaving a request pending forever was
216
- * the only way to say no, and it looks identical to not having read it.
217
- */
218
- async function declineTask(input) {
219
- const session = await workstreams.requireOpenSession(input, "decline a task");
220
- const now = clock.now();
221
- let record = null;
222
- await store.transaction(async tx => {
223
- const existing = tx.get("task", input.taskId);
224
- if (existing === null) {
225
- throw new AccError(EXIT.DATA, "the task does not exist", { taskId: input.taskId });
226
- }
227
- record = { ...existing, assigneeParticipantId: null, assigneeSessionId: null,
228
- state: "pending" };
229
- tx.put("task", input.taskId, record, tx.generationOf("task", input.taskId));
230
- writeWorkResponse(tx, { task: existing, actor: session,
231
- workspaceId: session.workspaceId, now, ids, outcome: "declined",
232
- reason: input.reason ?? null });
233
- // Refusing is also an answer, so the request stops demanding one.
234
- closeRequestReceipt(tx, { task: existing, actor: session, now, ids });
235
- tx.append({ schemaVersion: SCHEMA_VERSION, eventId: ids.next("event"),
236
- workspaceId: session.workspaceId, actorSessionId: session.sessionId,
237
- type: "task.declined", occurredAt: now,
238
- payload: { taskId: input.taskId, reason: input.reason ?? null } });
239
- }, { kinds: ["message", "receipt", "task"] });
240
- return record;
241
- }
242
-
243
- return { createTask, claimTask, transitionTask, declineTask };
244
- }