agents-can-communicate 0.1.16 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -133
- package/bin/acc-hook.mjs +2 -0
- package/docs/CAPABILITIES.md +105 -85
- package/node_modules/@agents-can-communicate/adapter-claude-code/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-claude-code/plugin/skills/acc/SKILL.md +80 -154
- package/node_modules/@agents-can-communicate/adapter-claude-code/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-codex/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-codex/plugin/skills/acc/SKILL.md +80 -154
- package/node_modules/@agents-can-communicate/adapter-codex/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/extension/skills/acc/SKILL.md +80 -154
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-gemini-cli/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-grok/package.json +13 -0
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/hooks/hooks.json +61 -0
- package/node_modules/@agents-can-communicate/adapter-grok/plugin/skills/acc/SKILL.md +154 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/adapter.mjs +61 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/hooks.mjs +127 -0
- package/node_modules/@agents-can-communicate/adapter-grok/src/install.mjs +101 -0
- package/node_modules/@agents-can-communicate/adapter-kimi/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-kimi/plugin/skills/acc/SKILL.md +80 -154
- package/node_modules/@agents-can-communicate/adapter-kimi/src/adapter.mjs +3 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/package.json +1 -1
- package/node_modules/@agents-can-communicate/adapter-sdk/src/context-projector.mjs +125 -189
- package/node_modules/@agents-can-communicate/adapter-sdk/src/index.mjs +1 -1
- package/node_modules/@agents-can-communicate/cli/package.json +1 -1
- package/node_modules/@agents-can-communicate/cli/src/args.mjs +3 -0
- package/node_modules/@agents-can-communicate/cli/src/help.mjs +4 -2
- package/node_modules/@agents-can-communicate/cli/src/install-command.mjs +3 -1
- package/node_modules/@agents-can-communicate/cli/src/main.mjs +23 -2
- package/node_modules/@agents-can-communicate/cli/src/session-owner.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/package.json +1 -1
- package/node_modules/@agents-can-communicate/core/src/inbox.mjs +134 -0
- package/node_modules/@agents-can-communicate/core/src/index.mjs +1 -0
- package/node_modules/@agents-can-communicate/core/src/message-signals.mjs +41 -0
- package/node_modules/@agents-can-communicate/core/src/ports.mjs +1 -1
- package/node_modules/@agents-can-communicate/core/src/service.mjs +3 -0
- package/node_modules/@agents-can-communicate/core/src/sessions.mjs +48 -0
- package/node_modules/@agents-can-communicate/core/src/sync.mjs +36 -0
- package/node_modules/@agents-can-communicate/hook-runner/package.json +1 -1
- package/node_modules/@agents-can-communicate/hook-runner/src/runner.mjs +77 -33
- package/node_modules/@agents-can-communicate/installer/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/package.json +1 -1
- package/node_modules/@agents-can-communicate/mcp-server/src/server.mjs +29 -21
- package/node_modules/@agents-can-communicate/mcp-server/src/tools.mjs +25 -1
- package/node_modules/@agents-can-communicate/protocol/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/package.json +1 -1
- package/node_modules/@agents-can-communicate/storage-filesystem/src/store.mjs +23 -7
- package/node_modules/@agents-can-communicate/storage-filesystem/src/writer-mutex.mjs +18 -2
- package/package.json +4 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A note is fire-and-forget: shown to the recipient once, no acknowledgement
|
|
2
|
+
// owed, no standing reminder. Agents put decisions and warnings in notes anyway
|
|
3
|
+
// - in one measured session a note carrying a merge decision was lost for three
|
|
4
|
+
// hours - so this is the send-time tell that a note is waiting on a reply and
|
|
5
|
+
// should have gone out as a question (`--requires-ack`) or a decision.
|
|
6
|
+
//
|
|
7
|
+
// A nudge, never a block, and deliberately not a keyword list: keyword lists
|
|
8
|
+
// only fire in the language they were written in, and the agents here write in
|
|
9
|
+
// several. A question mark marks a question in every script, and the warning
|
|
10
|
+
// sign is the one glyph a peer reaches for when something must not be missed.
|
|
11
|
+
const QUESTION = /[??]/u;
|
|
12
|
+
const WARNING = /⚠/u;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Does this note read like it needs a response from the recipient?
|
|
16
|
+
*
|
|
17
|
+
* Pure and total: a missing subject or body is empty text, not an error, so the
|
|
18
|
+
* caller can hand it a message record without pre-checking its shape.
|
|
19
|
+
*/
|
|
20
|
+
export function looksConsequential({ subject = "", body = "" } = {}) {
|
|
21
|
+
const text = `${subject}\n${body}`;
|
|
22
|
+
return QUESTION.test(text) || WARNING.test(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NUDGE = "this note reads like it needs a reply, but a note is fire-and-forget: "
|
|
26
|
+
+ "the recipient sees it once and owes no response. Re-send with --requires-ack "
|
|
27
|
+
+ "for an answer, or record it with `acc decide`.";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The send-time advisory for a note that looks like it is waiting on a reply,
|
|
31
|
+
* or null when there is nothing to say.
|
|
32
|
+
*
|
|
33
|
+
* Only notes, and only ones the sender did not already mark `requiresAck`: a
|
|
34
|
+
* question already keeps a standing reminder, and nudging it would be noise.
|
|
35
|
+
* A nudge, never a block - the send has already happened; this only tells the
|
|
36
|
+
* sender the shape it chose does not carry the weight the words do.
|
|
37
|
+
*/
|
|
38
|
+
export function noteNudge(message) {
|
|
39
|
+
return message?.type === "note" && message?.requiresAck !== true
|
|
40
|
+
&& looksConsequential(message ?? {}) ? NUDGE : null;
|
|
41
|
+
}
|
|
@@ -23,7 +23,7 @@ const REQUIRED = Object.freeze({
|
|
|
23
23
|
// are storage too, so they hang off the store rather than becoming a fourth
|
|
24
24
|
// port. They are deliberately outside transactions: they append no events and
|
|
25
25
|
// vanish with their session.
|
|
26
|
-
const EPHEMERAL = ["get", "put", "delete", "list"];
|
|
26
|
+
const EPHEMERAL = ["get", "put", "update", "delete", "list"];
|
|
27
27
|
|
|
28
28
|
// Ports are validated at construction rather than at first use. A core that
|
|
29
29
|
// silently falls back to ambient time or randomness produces tests that pass
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createClaimService } from "./claims.mjs";
|
|
2
2
|
import { createCommunicationService } from "./communication.mjs";
|
|
3
3
|
import { createIntentService } from "./intents.mjs";
|
|
4
|
+
import { createInboxService } from "./inbox.mjs";
|
|
4
5
|
import { defaultPidIsAlive } from "./pid.mjs";
|
|
5
6
|
import { assertPorts } from "./ports.mjs";
|
|
6
7
|
import { createSessionService } from "./sessions.mjs";
|
|
@@ -32,6 +33,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
32
33
|
const tasks = createTaskService(ports, workstreams);
|
|
33
34
|
const claims = createClaimService(ports, sessions);
|
|
34
35
|
const communication = createCommunicationService(ports, sessions, claims);
|
|
36
|
+
const inbox = createInboxService(ports, sessions);
|
|
35
37
|
const sync = createSyncService(ports, sessions);
|
|
36
38
|
const status = createStatusService(ports, sessions);
|
|
37
39
|
const guardState = createGuardStateService(ports);
|
|
@@ -46,6 +48,7 @@ export function createCoordinationService({ store, clock, ids,
|
|
|
46
48
|
...tasks,
|
|
47
49
|
...claims,
|
|
48
50
|
...communication,
|
|
51
|
+
...inbox,
|
|
49
52
|
...sync,
|
|
50
53
|
...status,
|
|
51
54
|
guardState,
|
|
@@ -179,6 +179,53 @@ export function createSessionService(ports) {
|
|
|
179
179
|
return beaten;
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Continue the exact session named by a harness binding.
|
|
184
|
+
*
|
|
185
|
+
* Some clients emit SessionStart again after compacting their model context.
|
|
186
|
+
* The binding is already the continuation token: it names both the session
|
|
187
|
+
* and its generation. Refreshing that record preserves one identity without
|
|
188
|
+
* pretending an unrelated or closed generation is still ours.
|
|
189
|
+
*
|
|
190
|
+
* Returns null when the binding can no longer be resumed, so the hook may
|
|
191
|
+
* open a genuinely new session. No semantic event is appended: compaction is
|
|
192
|
+
* not a second agent arriving.
|
|
193
|
+
*/
|
|
194
|
+
async function resumeSession({ sessionId, workspaceId, generation, ...metadata }) {
|
|
195
|
+
const resume = current => {
|
|
196
|
+
if (current === null || current.state !== "open"
|
|
197
|
+
|| current.generation !== generation) return null;
|
|
198
|
+
return { ...current,
|
|
199
|
+
pid: metadata.pid ?? null,
|
|
200
|
+
checkoutRoot: metadata.checkoutRoot ?? current.checkoutRoot,
|
|
201
|
+
branch: metadata.branch ?? current.branch,
|
|
202
|
+
enforcement: metadata.enforcement ?? current.enforcement,
|
|
203
|
+
lifecycle: metadata.lifecycle ?? current.lifecycle,
|
|
204
|
+
heartbeatCadenceMs: metadata.heartbeatCadenceMs ?? current.heartbeatCadenceMs,
|
|
205
|
+
heartbeatAt: clock.now(),
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// The compare and replacement happen under the ephemeral store's writer
|
|
210
|
+
// lock. A close or a replacement generation can win before this update or
|
|
211
|
+
// after it, but can never be overwritten from a record read beforehand.
|
|
212
|
+
const ephemeral = await store.ephemeral.update("session", sessionId, resume);
|
|
213
|
+
if (ephemeral !== null) return ephemeral;
|
|
214
|
+
|
|
215
|
+
// Re-read and validate inside the durable transaction for the same reason.
|
|
216
|
+
// Using generationOf only as the put token is insufficient: it protects
|
|
217
|
+
// the envelope write, not the semantic generation carried by the record.
|
|
218
|
+
const resolvedWorkspace = workspaceId ?? store.workspaceId;
|
|
219
|
+
if (resolvedWorkspace === undefined) return null;
|
|
220
|
+
return store.transaction(async tx => {
|
|
221
|
+
const current = tx.get("session", sessionId);
|
|
222
|
+
const resumed = resume(current);
|
|
223
|
+
if (resumed === null) return null;
|
|
224
|
+
tx.put("session", sessionId, resumed, tx.generationOf("session", sessionId));
|
|
225
|
+
return resumed;
|
|
226
|
+
}, { kinds: ["session"] });
|
|
227
|
+
}
|
|
228
|
+
|
|
182
229
|
async function closeSession({ sessionId, workspaceId, generation }) {
|
|
183
230
|
const existing = await locate(sessionId, workspaceId);
|
|
184
231
|
if (existing === null) throw new AccError(EXIT.CONFLICT, "session is not open", { sessionId });
|
|
@@ -222,6 +269,7 @@ export function createSessionService(ports) {
|
|
|
222
269
|
|
|
223
270
|
return {
|
|
224
271
|
openSession,
|
|
272
|
+
resumeSession,
|
|
225
273
|
heartbeatSession,
|
|
226
274
|
closeSession,
|
|
227
275
|
locateSession: locate,
|
|
@@ -46,6 +46,7 @@ export const ATTENTION_PRIORITY = Object.freeze({
|
|
|
46
46
|
request_stalled: 5,
|
|
47
47
|
claim_expired: 6,
|
|
48
48
|
claim_contended: 7,
|
|
49
|
+
unread_note: 8,
|
|
49
50
|
});
|
|
50
51
|
|
|
51
52
|
function directRequests(snapshot, participantId) {
|
|
@@ -61,6 +62,40 @@ function directRequests(snapshot, participantId) {
|
|
|
61
62
|
return items;
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A note that was delivered once and then left unanswered.
|
|
67
|
+
*
|
|
68
|
+
* A `note` carries no acknowledgement obligation, so unlike a `requiresAck`
|
|
69
|
+
* message it raises no direct_request and, once injected, drops out of the
|
|
70
|
+
* inbox for good. Agents put decisions in notes anyway, and one delivered that
|
|
71
|
+
* way was missed for three hours because nothing stood behind it. This is the
|
|
72
|
+
* single low-priority breadcrumb that keeps a delivered-but-unacknowledged note
|
|
73
|
+
* recoverable: it fires only while the receipt reads `injected`, so the runner
|
|
74
|
+
* advancing it to `seen` after one showing makes it one-shot rather than a
|
|
75
|
+
* standing nag - the very noise a reader learns to skip. A `queued` note is
|
|
76
|
+
* about to be shown in full this turn and needs no breadcrumb yet.
|
|
77
|
+
*
|
|
78
|
+
* Only the recipient's, and named by message id so `acc inbox --message` or
|
|
79
|
+
* `acc ack --message` can act on it without a workspace-wide lookup.
|
|
80
|
+
*/
|
|
81
|
+
function unreadNotes(snapshot, participantId) {
|
|
82
|
+
const items = [];
|
|
83
|
+
for (const receipt of snapshot.receipts ?? []) {
|
|
84
|
+
if (receipt.recipientParticipantId !== participantId) continue;
|
|
85
|
+
if (receipt.state !== "injected") continue;
|
|
86
|
+
const message = (snapshot.messages ?? [])
|
|
87
|
+
.find(item => item.messageId === receipt.messageId);
|
|
88
|
+
// A requiresAck message already carries a standing direct_request; a second
|
|
89
|
+
// line here would be two reminders for one obligation.
|
|
90
|
+
if (message === undefined || message.requiresAck) continue;
|
|
91
|
+
items.push({ kind: "unread_note", priority: ATTENTION_PRIORITY.unread_note,
|
|
92
|
+
sourceId: message.messageId,
|
|
93
|
+
summary: `a note you have not acknowledged - \`acc inbox --message `
|
|
94
|
+
+ `${message.messageId}\` to read it` });
|
|
95
|
+
}
|
|
96
|
+
return items;
|
|
97
|
+
}
|
|
98
|
+
|
|
64
99
|
/**
|
|
65
100
|
* A claim of yours that has run out.
|
|
66
101
|
*
|
|
@@ -245,6 +280,7 @@ export function computeAttention(snapshot, { session, participantId, now, pidIsA
|
|
|
245
280
|
}
|
|
246
281
|
return [
|
|
247
282
|
...directRequests(snapshot, participantId),
|
|
283
|
+
...unreadNotes(snapshot, participantId),
|
|
248
284
|
...claimConflicts(snapshot, session, now),
|
|
249
285
|
...claimContended(snapshot, session, now),
|
|
250
286
|
...expiredClaims(snapshot, session, now),
|
|
@@ -13,6 +13,11 @@ import { createGitProbe, discoverWorkspace, platformDataHome, runtimePaths }
|
|
|
13
13
|
import { resolveClientPid } from "./client-pid.mjs";
|
|
14
14
|
import { readProcessTable as defaultReadProcessTable } from "./process-table.mjs";
|
|
15
15
|
|
|
16
|
+
// Kept cohesive above 300 lines because every handler shares one fail-open
|
|
17
|
+
// hook boundary, binding lifecycle, and client-specific outcome contract.
|
|
18
|
+
// Splitting handlers would duplicate that safety boundary and make delivery or
|
|
19
|
+
// guard failures behave differently by event kind.
|
|
20
|
+
|
|
16
21
|
// A hook runs in front of the user's turn, so it gets a hard ceiling. Better to
|
|
17
22
|
// let a call through than to make someone's session sit waiting on us.
|
|
18
23
|
const DEFAULT_BUDGET_MS = 5_000;
|
|
@@ -157,7 +162,8 @@ async function openContext({ cwd, dataHome, runtime, env }) {
|
|
|
157
162
|
}
|
|
158
163
|
|
|
159
164
|
const HANDLERS = {
|
|
160
|
-
async sessionStart({ event, context, adapter, adapterId, paths,
|
|
165
|
+
async sessionStart({ event, context, adapter, adapterId, binding, paths,
|
|
166
|
+
readProcessTable }) {
|
|
161
167
|
const capabilities = adapter.capabilities ?? {};
|
|
162
168
|
// Once per session, never per turn. A client that cannot be found yields
|
|
163
169
|
// null, and the session is then judged by age alone - which is exactly the
|
|
@@ -165,6 +171,24 @@ const HANDLERS = {
|
|
|
165
171
|
const command = adapter.client?.command ?? null;
|
|
166
172
|
const pid = command === null ? null
|
|
167
173
|
: resolveClientPid({ table: await readProcessTable(), from: process.pid, command });
|
|
174
|
+
const metadata = {
|
|
175
|
+
pid,
|
|
176
|
+
enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
|
|
177
|
+
lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
|
|
178
|
+
heartbeatCadenceMs: CADENCE_MS,
|
|
179
|
+
checkoutRoot: context.descriptor.git?.worktreeRoot ?? context.descriptor.roots[0],
|
|
180
|
+
branch: context.descriptor.git?.branch ?? null,
|
|
181
|
+
};
|
|
182
|
+
if (binding !== null) {
|
|
183
|
+
const resumed = await context.service.resumeSession({
|
|
184
|
+
sessionId: binding.accSessionId,
|
|
185
|
+
generation: binding.generation,
|
|
186
|
+
...metadata,
|
|
187
|
+
});
|
|
188
|
+
if (resumed !== null) {
|
|
189
|
+
return { accSessionId: resumed.sessionId, generation: resumed.generation };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
168
192
|
const session = await context.service.openSession({
|
|
169
193
|
workspaceId: context.descriptor.id,
|
|
170
194
|
participantId: participantFor(adapterId, event.sessionId, context.env),
|
|
@@ -174,14 +198,9 @@ const HANDLERS = {
|
|
|
174
198
|
// Declared from what this adapter proved, not from the fact that it is an
|
|
175
199
|
// adapter at all. A peer reading the roster can then tell a session whose
|
|
176
200
|
// writes can be stopped from one whose cannot.
|
|
177
|
-
enforcement: capabilities.guards?.beforeWrite === true ? "guarded" : "advisory",
|
|
178
|
-
lifecycle: capabilities.lifecycle?.sessionEnd === true ? "managed" : "manual",
|
|
179
|
-
heartbeatCadenceMs: CADENCE_MS,
|
|
180
201
|
// Which checkout this agent is in. One workspace spans every worktree of
|
|
181
202
|
// a repository, so this is the only thing that distinguishes them.
|
|
182
|
-
|
|
183
|
-
pid,
|
|
184
|
-
branch: context.descriptor.git?.branch ?? null,
|
|
203
|
+
...metadata,
|
|
185
204
|
descriptor: context.descriptor,
|
|
186
205
|
});
|
|
187
206
|
await storeSessionBinding({ runtimeDir: paths.root, harnessSessionId: event.sessionId,
|
|
@@ -213,27 +232,11 @@ const HANDLERS = {
|
|
|
213
232
|
const sync = await context.service.sync({ sessionId: binding.accSessionId,
|
|
214
233
|
cursor: null, scope: "delta" });
|
|
215
234
|
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
// plainly that the responsibility has moved to the session itself.
|
|
221
|
-
const status = await context.service.collectStatus({
|
|
222
|
-
workspaceId: context.descriptor.id });
|
|
223
|
-
const mine = status.participants
|
|
235
|
+
// Sync already carries the roster. Calling collectStatus here used to read
|
|
236
|
+
// the entire materialised store a second time on every prompt merely to
|
|
237
|
+
// rediscover this session's participant id.
|
|
238
|
+
const mine = sync.roster
|
|
224
239
|
.find(participant => participant.sessionId === binding.accSessionId);
|
|
225
|
-
// Two independent facts, and both are needed. `enforceable` is whether ACC
|
|
226
|
-
// could stop *this* session at all; `enforcement` is what the claim's owner
|
|
227
|
-
// asked for. A guarded session facing an advisory claim is not blocked from
|
|
228
|
-
// anything, so reporting either one alone mislabels the other case.
|
|
229
|
-
const enforceable = mine?.enforcement === "guarded";
|
|
230
|
-
const claims = status.claims
|
|
231
|
-
.filter(claim => claim.ownerSessionId !== binding.accSessionId)
|
|
232
|
-
.map(claim => ({ resource: claim.resource, enforcement: claim.enforcement,
|
|
233
|
-
enforceable,
|
|
234
|
-
ownerParticipantId: status.participants
|
|
235
|
-
.find(p => p.sessionId === claim.ownerSessionId)?.participantId
|
|
236
|
-
?? claim.ownerSessionId }));
|
|
237
240
|
|
|
238
241
|
// What peers have said to this participant and no model has been shown yet.
|
|
239
242
|
// Without this the projector's peer block never ran in production: an agent
|
|
@@ -254,31 +257,72 @@ const HANDLERS = {
|
|
|
254
257
|
// The ceiling a team agreed on in `acc.workspace.json`, or the default when
|
|
255
258
|
// there is no config. Validated by the protocol and, until now, never read:
|
|
256
259
|
// the projector was always called with its own default.
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
+
const tracksDelivery = typeof adapter.renderContextResult === "function";
|
|
261
|
+
const projectionInput = { ...sync, messages: tracksDelivery ? messages : [],
|
|
262
|
+
currentParticipantId: mine?.participantId };
|
|
263
|
+
const projectionOptions = {
|
|
264
|
+
budgetBytes: context.descriptor.policy?.contextBudgetBytes };
|
|
265
|
+
// Delivery is state, not text parsing. Peer-controlled bodies can imitate
|
|
266
|
+
// another message's visible header, so only projector metadata proves
|
|
267
|
+
// which complete groups survived the byte budget. A custom adapter without
|
|
268
|
+
// metadata may still inject text, but cannot advance a receipt from it.
|
|
269
|
+
const projection = !tracksDelivery
|
|
270
|
+
? { text: await adapter.renderContext?.(projectionInput, projectionOptions) ?? "",
|
|
271
|
+
includedMessageIds: [], includedAttentionIds: [] }
|
|
272
|
+
: await adapter.renderContextResult(projectionInput, projectionOptions);
|
|
273
|
+
const degradation = !tracksDelivery && messages.length > 0
|
|
274
|
+
? `acc: ${messages.length} pending message(s) withheld because this adapter lacks `
|
|
275
|
+
+ `structured delivery metadata; read ${messages[0].messageId} with `
|
|
276
|
+
+ `acc inbox --message ${messages[0].messageId}`
|
|
277
|
+
: null;
|
|
278
|
+
const visibleDegradation = degradation === null ? "" : `ACC: ${degradation.slice(5)}`;
|
|
279
|
+
const candidate = [projection.text, visibleDegradation].filter(Boolean).join("\n");
|
|
280
|
+
const projected = Buffer.byteLength(candidate, "utf8")
|
|
281
|
+
<= (projectionOptions.budgetBytes ?? 6_000) ? candidate : projection.text;
|
|
282
|
+
if (projected === "") {
|
|
283
|
+
return degradation === null ? { stdout: "" } : { stdout: "", stderr: degradation };
|
|
284
|
+
}
|
|
260
285
|
|
|
261
286
|
// Only what the model was actually shown is recorded as delivered. The
|
|
262
287
|
// budget can leave a message out, and a receipt reading `injected` for text
|
|
263
288
|
// nobody saw is worse than one still reading `queued` - the sender would be
|
|
264
289
|
// told it landed. A message left behind stays queued and goes out next turn.
|
|
265
290
|
const failures = [];
|
|
291
|
+
const includedMessages = new Set(projection.includedMessageIds ?? []);
|
|
266
292
|
for (const message of messages) {
|
|
267
|
-
if (!
|
|
293
|
+
if (!includedMessages.has(message.messageId)) continue;
|
|
268
294
|
await context.service.markDelivery({ sessionId: binding.accSessionId,
|
|
269
295
|
generation: binding.generation, messageId: message.messageId,
|
|
270
296
|
recipientParticipantId: mine.participantId, state: "injected" })
|
|
271
297
|
.catch(error => failures.push(`${message.messageId}: ${error.message}`));
|
|
272
298
|
}
|
|
299
|
+
// A note carries no ack obligation, so after its one full showing it leaves
|
|
300
|
+
// a single low-priority `unread_note` breadcrumb. Advancing that receipt
|
|
301
|
+
// injected -> seen the turn the breadcrumb is shown is what makes it
|
|
302
|
+
// one-shot: next turn the note reads `seen`, the breadcrumb stays quiet, and
|
|
303
|
+
// a delivered decision is recoverable without becoming a standing nag - the
|
|
304
|
+
// noise a reader learns to skip. Only what was actually shown is advanced,
|
|
305
|
+
// for the same reason the loop above only records what fit.
|
|
306
|
+
const includedAttention = new Set(projection.includedAttentionIds ?? []);
|
|
307
|
+
for (const item of sync.attention ?? []) {
|
|
308
|
+
if (item.kind !== "unread_note") continue;
|
|
309
|
+
if (!includedAttention.has(item.sourceId)) continue;
|
|
310
|
+
await context.service.markDelivery({ sessionId: binding.accSessionId,
|
|
311
|
+
generation: binding.generation, messageId: item.sourceId,
|
|
312
|
+
recipientParticipantId: mine.participantId, state: "seen" })
|
|
313
|
+
.catch(error => failures.push(`${item.sourceId}: ${error.message}`));
|
|
314
|
+
}
|
|
273
315
|
// Same again: Kimi Code shows the model a hook's raw stdout, while Gemini
|
|
274
316
|
// and Claude Code want an envelope and drop a bare string.
|
|
275
317
|
// Reported rather than swallowed. The context still goes out - losing it
|
|
276
318
|
// over bookkeeping would be the worse trade - but a receipt that failed to
|
|
277
319
|
// advance has to be visible somewhere, and stdout belongs to the model.
|
|
278
320
|
const outcome = { stdout: "", ...adapter.injectOutcome?.(projected) };
|
|
279
|
-
if (failures.length === 0) return outcome;
|
|
321
|
+
if (failures.length === 0 && degradation === null) return outcome;
|
|
280
322
|
return { ...outcome,
|
|
281
|
-
stderr: [outcome.stderr,
|
|
323
|
+
stderr: [outcome.stderr, degradation,
|
|
324
|
+
failures.length === 0 ? null
|
|
325
|
+
: `acc: delivery not recorded for ${failures.join(", ")}`]
|
|
282
326
|
.filter(Boolean).join("\n") };
|
|
283
327
|
},
|
|
284
328
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
2
|
+
import { noteNudge } from "@agents-can-communicate/core";
|
|
2
3
|
import { clearSessionBinding, loadSessionBinding, storeSessionBinding }
|
|
3
4
|
from "@agents-can-communicate/adapter-sdk";
|
|
4
5
|
|
|
@@ -71,6 +72,22 @@ async function resolveSession(context) {
|
|
|
71
72
|
return session;
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
// Kept for clients released before acc_inbox existed. New clients should use
|
|
76
|
+
// the narrow inbox tool, but removing mail from acc_sync would strand deployed
|
|
77
|
+
// clients that only know this response field. Returning a body is delivery, so
|
|
78
|
+
// only those returned messages advance to injected.
|
|
79
|
+
async function syncWithMail(service, owner, context, args) {
|
|
80
|
+
const sync = await service.sync({ ...owner, cursor: args.cursor ?? null,
|
|
81
|
+
scope: args.scope, limit: args.limit });
|
|
82
|
+
const messages = await service.pendingMessages({ workspaceId: context.workspaceId,
|
|
83
|
+
participantId: context.participantId, exceptSessionId: owner.sessionId });
|
|
84
|
+
for (const message of messages) {
|
|
85
|
+
await service.markDelivery({ ...owner, messageId: message.messageId,
|
|
86
|
+
state: "injected" }).catch(() => null);
|
|
87
|
+
}
|
|
88
|
+
return messages.length === 0 ? sync : { ...sync, messages };
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
/**
|
|
75
92
|
* A poll is this client's turn.
|
|
76
93
|
*
|
|
@@ -89,25 +106,6 @@ async function resolveSession(context) {
|
|
|
89
106
|
* Acknowledgement stays a separate act, because being shown something is not
|
|
90
107
|
* agreeing to it.
|
|
91
108
|
*/
|
|
92
|
-
async function syncWithMail(service, owner, context, args) {
|
|
93
|
-
const sync = await service.sync({ ...owner, cursor: args.cursor ?? null,
|
|
94
|
-
scope: args.scope, limit: args.limit });
|
|
95
|
-
const messages = await service.pendingMessages({
|
|
96
|
-
workspaceId: context.workspaceId,
|
|
97
|
-
participantId: context.participantId,
|
|
98
|
-
exceptSessionId: owner.sessionId });
|
|
99
|
-
if (messages.length === 0) return sync;
|
|
100
|
-
|
|
101
|
-
for (const message of messages) {
|
|
102
|
-
// One failure must not swallow the rest: the client is holding the message
|
|
103
|
-
// either way, and a receipt that cannot be written is not a reason to hide
|
|
104
|
-
// what a peer said.
|
|
105
|
-
await service.markDelivery({ ...owner, messageId: message.messageId,
|
|
106
|
-
state: "injected" }).catch(() => null);
|
|
107
|
-
}
|
|
108
|
-
return { ...sync, messages };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
109
|
async function callTool(name, args, context) {
|
|
112
110
|
const session = await resolveSession(context);
|
|
113
111
|
const owner = { sessionId: session.sessionId, generation: session.generation,
|
|
@@ -130,11 +128,21 @@ async function callTool(name, args, context) {
|
|
|
130
128
|
return service.acquireClaim({ ...owner, resource: args.resource,
|
|
131
129
|
mode: args.mode ?? "exclusive", enforcement: "advisory",
|
|
132
130
|
reason: args.reason ?? "unspecified", leaseSeconds: args.leaseSeconds });
|
|
133
|
-
case "acc_message":
|
|
134
|
-
|
|
131
|
+
case "acc_message": {
|
|
132
|
+
const message = await service.sendMessage({ ...owner, toParticipantIds: args.to ?? [],
|
|
135
133
|
subject: args.subject, body: args.body, type: args.type ?? "note",
|
|
136
134
|
priority: args.priority, requiresAck: args.requiresAck === true,
|
|
137
135
|
workstreamId: args.workstreamId ?? null });
|
|
136
|
+
// A note that reads like it wants a reply was sent fire-and-forget; hand
|
|
137
|
+
// the nudge back with the message so the model that sent it can reconsider.
|
|
138
|
+
const advice = noteNudge(message);
|
|
139
|
+
return advice ? { ...message, advice } : message;
|
|
140
|
+
}
|
|
141
|
+
case "acc_inbox":
|
|
142
|
+
return service.readInbox({ ...owner, messageId: args.messageId });
|
|
143
|
+
case "acc_reply":
|
|
144
|
+
return service.replyToMessage({ ...owner, messageId: args.messageId,
|
|
145
|
+
body: args.body, subject: args.subject, type: args.type, priority: args.priority });
|
|
138
146
|
case "acc_task":
|
|
139
147
|
if (args.action === "claim") return service.claimTask({ ...owner,
|
|
140
148
|
taskId: args.taskId, force: args.force === true });
|
|
@@ -24,7 +24,8 @@ export const PUBLIC_TOOLS = Object.freeze([
|
|
|
24
24
|
name: "acc_sync",
|
|
25
25
|
description: `Read coordination state for this workspace: roster, attention items, and `
|
|
26
26
|
+ `events since a cursor. Use scope "full" to answer questions about the whole `
|
|
27
|
-
+ `workspace, including other participants' collapsed child sessions.
|
|
27
|
+
+ `workspace, including other participants' collapsed child sessions. Pending mail is `
|
|
28
|
+
+ `also returned for compatibility; prefer acc_inbox for targeted reads. ${POLLED}`,
|
|
28
29
|
inputSchema: object({
|
|
29
30
|
cursor: string("Resume from this cursor; omit to start from the beginning."),
|
|
30
31
|
scope: { type: "string", enum: ["delta", "full"],
|
|
@@ -81,6 +82,29 @@ export const PUBLIC_TOOLS = Object.freeze([
|
|
|
81
82
|
workstreamId: string("Optional workstream context."),
|
|
82
83
|
}, ["to", "subject", "body"]),
|
|
83
84
|
},
|
|
85
|
+
{
|
|
86
|
+
name: "acc_inbox",
|
|
87
|
+
description: `Read unresolved messages addressed to this participant without loading `
|
|
88
|
+
+ `the roster, event log, claims, or workspace snapshot. Optionally select one id. `
|
|
89
|
+
+ `${POLLED}`,
|
|
90
|
+
inputSchema: object({
|
|
91
|
+
messageId: string("Read exactly this addressed message; omit for all unresolved mail."),
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "acc_reply",
|
|
96
|
+
description: `Reply to one addressed message and acknowledge the original in the same `
|
|
97
|
+
+ `operation. The reply is attributed, linked with inReplyTo, and delivered by polling. `
|
|
98
|
+
+ `${POLLED}`,
|
|
99
|
+
inputSchema: object({
|
|
100
|
+
messageId: string("The addressed message being answered."),
|
|
101
|
+
body: string("Concise answer; peer content is treated as data."),
|
|
102
|
+
subject: string("Optional subject; defaults to Re: the original subject."),
|
|
103
|
+
type: { type: "string", enum: ["answer", "contract_response", "decision_result",
|
|
104
|
+
"review_result", "work_response"] },
|
|
105
|
+
priority: { type: "string", enum: ["low", "normal", "high", "urgent"] },
|
|
106
|
+
}, ["messageId", "body"]),
|
|
107
|
+
},
|
|
84
108
|
{
|
|
85
109
|
name: "acc_request",
|
|
86
110
|
description: `Ask another agent to do something. Creates the work addressed to them `
|
|
@@ -12,6 +12,10 @@ import { assertEventBinding, assertStateBinding, eventPath, stateEnvelope, state
|
|
|
12
12
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
13
13
|
import { withWriterMutex } from "./writer-mutex.mjs";
|
|
14
14
|
|
|
15
|
+
// Kept cohesive above 300 lines because durable transactions and ephemeral
|
|
16
|
+
// mutations must share this exact writer mutex. Splitting the two stores would
|
|
17
|
+
// make it easy to reintroduce separate locks and resurrect replaced sessions.
|
|
18
|
+
|
|
15
19
|
const SEQUENCE_WIDTH = 16;
|
|
16
20
|
export const ZERO_CURSOR = "0".repeat(SEQUENCE_WIDTH);
|
|
17
21
|
// No quarantine area. One was created in every workspace, named in the path
|
|
@@ -260,22 +264,34 @@ export async function openFilesystemStore({ root, clock, ids, workspaceId, failA
|
|
|
260
264
|
handoffs: await of("handoff"),
|
|
261
265
|
};
|
|
262
266
|
}
|
|
263
|
-
|
|
264
267
|
// Ephemeral records are published by replace and never journalled: they carry
|
|
265
268
|
// no history, append no events, and are expected to disappear.
|
|
266
269
|
const ephemeralPath = (kind, id) => path.join(paths.ephemeral, kind, `${id}.json`);
|
|
267
270
|
const ephemeral = Object.freeze({
|
|
268
271
|
async get(kind, id) {
|
|
269
|
-
|
|
270
|
-
return found?.value ?? null;
|
|
272
|
+
return (await readJsonIfPresent(ephemeralPath(kind, id), root))?.value ?? null;
|
|
271
273
|
},
|
|
272
274
|
async put(kind, id, record) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
275
|
+
return withWriterMutex(paths, publishOptions, async () => {
|
|
276
|
+
await publishAtomic(ephemeralPath(kind, id), encode(record),
|
|
277
|
+
{ root, tmpDir: paths.tmp, replace: true });
|
|
278
|
+
return record;
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
async update(kind, id, updater) {
|
|
282
|
+
return withWriterMutex(paths, publishOptions, async () => {
|
|
283
|
+
const found = await readJsonIfPresent(ephemeralPath(kind, id), root);
|
|
284
|
+
const next = await updater(found?.value ?? null);
|
|
285
|
+
if (next === null) return null;
|
|
286
|
+
validateRecord(kind, next);
|
|
287
|
+
await publishAtomic(ephemeralPath(kind, id), encode(next),
|
|
288
|
+
{ root, tmpDir: paths.tmp, replace: true });
|
|
289
|
+
return next;
|
|
290
|
+
});
|
|
276
291
|
},
|
|
277
292
|
async delete(kind, id) {
|
|
278
|
-
|
|
293
|
+
return withWriterMutex(paths, publishOptions,
|
|
294
|
+
async () => removeIfPresent(ephemeralPath(kind, id)));
|
|
279
295
|
},
|
|
280
296
|
async list(kind) {
|
|
281
297
|
const records = [];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { mkdir, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
4
5
|
|
|
5
6
|
import { AccError, EXIT } from "@agents-can-communicate/protocol";
|
|
6
7
|
|
|
@@ -8,7 +9,9 @@ import { encode, publishAtomic, readJsonIfPresent } from "./atomic-json.mjs";
|
|
|
8
9
|
import { ensureManagedDirectory } from "./safe-directory.mjs";
|
|
9
10
|
|
|
10
11
|
const STALE_MS = 60_000;
|
|
12
|
+
const ACQUIRE_TIMEOUT_MS = 2_500;
|
|
11
13
|
const OWNER = "owner.json";
|
|
14
|
+
const sleepFor = duration => new Promise(resolve => { setTimeout(resolve, duration); });
|
|
12
15
|
|
|
13
16
|
// Directory creation is the atomic primitive: mkdir either creates or fails
|
|
14
17
|
// with EEXIST, with no window in between. Ported from the reconciled
|
|
@@ -85,22 +88,35 @@ async function takeStaleOwnership(directory, root, owner, now, pidIsAlive) {
|
|
|
85
88
|
|
|
86
89
|
export async function withWriterMutex(paths, options, operation) {
|
|
87
90
|
const { root, clock, pidIsAlive = defaultPidIsAlive, uuid = randomUUID,
|
|
88
|
-
attempts =
|
|
91
|
+
attempts = Number.POSITIVE_INFINITY, waitMs = 20, openFile,
|
|
92
|
+
acquireTimeoutMs = ACQUIRE_TIMEOUT_MS, monotonicNow = () => performance.now(),
|
|
93
|
+
sleep = sleepFor } = options;
|
|
89
94
|
const directory = path.join(paths.locks, "writer.lock");
|
|
95
|
+
// Wall time can jump while a process waits. A monotonic absolute deadline
|
|
96
|
+
// bounds all owner reads and retries, leaving half the hook's five-second
|
|
97
|
+
// budget for publishing the owner, doing the write, and rendering a result.
|
|
98
|
+
const deadline = monotonicNow() + acquireTimeoutMs;
|
|
90
99
|
await ensureManagedDirectory(root, paths.locks);
|
|
91
100
|
const token = uuid();
|
|
92
101
|
|
|
93
102
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
103
|
+
if (monotonicNow() >= deadline) break;
|
|
94
104
|
try {
|
|
95
105
|
await mkdir(directory);
|
|
96
106
|
} catch (error) {
|
|
97
107
|
if (error.code !== "EEXIST") throw error;
|
|
98
108
|
const owner = await readOwner(directory, root, openFile);
|
|
99
109
|
if (!await takeStaleOwnership(directory, root, owner, clock.now(), pidIsAlive)) {
|
|
100
|
-
|
|
110
|
+
const remaining = deadline - monotonicNow();
|
|
111
|
+
if (remaining <= 0) break;
|
|
112
|
+
await sleep(Math.min(waitMs, remaining));
|
|
101
113
|
}
|
|
102
114
|
continue;
|
|
103
115
|
}
|
|
116
|
+
if (monotonicNow() >= deadline) {
|
|
117
|
+
await rm(directory, { recursive: true, force: true });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
104
120
|
const owner = { pid: process.pid, token, acquiredAt: clock.now() };
|
|
105
121
|
await publishAtomic(path.join(directory, OWNER), encode(owner), { root, tmpDir: paths.tmp });
|
|
106
122
|
try {
|