macro-agent 0.1.12 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-manager-v2.d.ts.map +1 -1
- package/dist/agent/agent-manager-v2.js +240 -7
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/types.d.ts +47 -0
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/boot-v2.d.ts +33 -0
- package/dist/boot-v2.d.ts.map +1 -1
- package/dist/boot-v2.js +142 -11
- package/dist/boot-v2.js.map +1 -1
- package/dist/cli/inbox-mcp-proxy.d.ts +36 -0
- package/dist/cli/inbox-mcp-proxy.d.ts.map +1 -0
- package/dist/cli/inbox-mcp-proxy.js +51 -0
- package/dist/cli/inbox-mcp-proxy.js.map +1 -0
- package/dist/dispatch/loadout-translation.d.ts +100 -0
- package/dist/dispatch/loadout-translation.d.ts.map +1 -0
- package/dist/dispatch/loadout-translation.js +90 -0
- package/dist/dispatch/loadout-translation.js.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts +89 -0
- package/dist/dispatch/mail-inbound-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-consumer.js +261 -0
- package/dist/dispatch/mail-inbound-consumer.js.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts +75 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.d.ts.map +1 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js +325 -0
- package/dist/dispatch/mail-inbound-reuse-consumer.js.map +1 -0
- package/dist/dispatch/permission-evaluator.d.ts +68 -0
- package/dist/dispatch/permission-evaluator.d.ts.map +1 -0
- package/dist/dispatch/permission-evaluator.js +159 -0
- package/dist/dispatch/permission-evaluator.js.map +1 -0
- package/dist/dispatch/permission-overlay.d.ts +64 -0
- package/dist/dispatch/permission-overlay.d.ts.map +1 -0
- package/dist/dispatch/permission-overlay.js +72 -0
- package/dist/dispatch/permission-overlay.js.map +1 -0
- package/dist/dispatch/permissions-handler.d.ts +71 -0
- package/dist/dispatch/permissions-handler.d.ts.map +1 -0
- package/dist/dispatch/permissions-handler.js +83 -0
- package/dist/dispatch/permissions-handler.js.map +1 -0
- package/dist/dispatch/spawn-agent-handler.d.ts +84 -0
- package/dist/dispatch/spawn-agent-handler.d.ts.map +1 -0
- package/dist/dispatch/spawn-agent-handler.js +85 -0
- package/dist/dispatch/spawn-agent-handler.js.map +1 -0
- package/dist/lifecycle/handlers-v2.d.ts +7 -0
- package/dist/lifecycle/handlers-v2.d.ts.map +1 -1
- package/dist/lifecycle/handlers-v2.js +27 -0
- package/dist/lifecycle/handlers-v2.js.map +1 -1
- package/dist/map/lifecycle-bridge.d.ts +18 -0
- package/dist/map/lifecycle-bridge.d.ts.map +1 -1
- package/dist/map/lifecycle-bridge.js +23 -1
- package/dist/map/lifecycle-bridge.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +55 -0
- package/dist/map/mail-bridge.d.ts.map +1 -0
- package/dist/map/mail-bridge.js +115 -0
- package/dist/map/mail-bridge.js.map +1 -0
- package/dist/map/sidecar.d.ts.map +1 -1
- package/dist/map/sidecar.js +245 -1
- package/dist/map/sidecar.js.map +1 -1
- package/dist/map/types.d.ts +15 -0
- package/dist/map/types.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.d.ts.map +1 -1
- package/dist/mcp/tools/done-v2.js +1 -0
- package/dist/mcp/tools/done-v2.js.map +1 -1
- package/dist/teams/team-loader.d.ts.map +1 -1
- package/dist/teams/team-loader.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +2 -0
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +6 -5
- package/src/agent/__tests__/agent-manager-v2.permission-interception.test.ts +296 -0
- package/src/agent/__tests__/agent-manager-v2.permissions.test.ts +233 -0
- package/src/agent/agent-manager-v2.ts +268 -8
- package/src/agent/types.ts +51 -0
- package/src/boot-v2.ts +190 -12
- package/src/cli/inbox-mcp-proxy.ts +56 -0
- package/src/dispatch/CLAUDE.md +129 -0
- package/src/dispatch/__tests__/loadout-translation.test.ts +141 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.integration.test.ts +519 -0
- package/src/dispatch/__tests__/mail-inbound-consumer.test.ts +589 -0
- package/src/dispatch/__tests__/mail-inbound-reuse-consumer.test.ts +575 -0
- package/src/dispatch/__tests__/permission-evaluator.test.ts +196 -0
- package/src/dispatch/__tests__/permission-overlay.test.ts +56 -0
- package/src/dispatch/__tests__/permissions-handler.test.ts +168 -0
- package/src/dispatch/__tests__/spawn-agent-handler.test.ts +282 -0
- package/src/dispatch/loadout-translation.ts +138 -0
- package/src/dispatch/mail-inbound-consumer.ts +397 -0
- package/src/dispatch/mail-inbound-reuse-consumer.ts +479 -0
- package/src/dispatch/permission-evaluator.ts +191 -0
- package/src/dispatch/permission-overlay.ts +89 -0
- package/src/dispatch/permissions-handler.ts +112 -0
- package/src/dispatch/spawn-agent-handler.ts +160 -0
- package/src/lifecycle/handlers-v2.ts +34 -0
- package/src/map/__tests__/lifecycle-bridge.test.ts +64 -0
- package/src/map/__tests__/mail-bridge.test.ts +196 -0
- package/src/map/lifecycle-bridge.ts +48 -2
- package/src/map/mail-bridge.ts +203 -0
- package/src/map/sidecar.ts +346 -1
- package/src/map/types.ts +21 -0
- package/src/mcp/tools/done-v2.ts +1 -0
- package/src/teams/team-loader.ts +3 -1
- package/src/teams/team-runtime-v2.ts +2 -0
- package/dist/workspace/dataplane-adapter.d.ts +0 -260
- package/dist/workspace/dataplane-adapter.d.ts.map +0 -1
- package/dist/workspace/dataplane-adapter.js +0 -416
- package/dist/workspace/dataplane-adapter.js.map +0 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mail-Inbound Reuse Consumer
|
|
3
|
+
*
|
|
4
|
+
* Receives hub-driven `x-dispatch/work` envelopes addressed to **non-sidecar**
|
|
5
|
+
* agents — long-lived team workers, coordinators, etc. — and drives them
|
|
6
|
+
* through the dispatch turn using their existing session, then posts the
|
|
7
|
+
* summary back as a mail turn.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors `mail-inbound-consumer.ts` but with three semantic differences:
|
|
10
|
+
*
|
|
11
|
+
* 1. Filters envelopes addressed to ANY non-sidecar agent (the existing
|
|
12
|
+
* consumer filters for the dispatcher recipient).
|
|
13
|
+
* 2. Does **not** spawn — it drives the existing agent's session via
|
|
14
|
+
* `agentManager.prompt(agentId, prompt)` and watches for `done()` in
|
|
15
|
+
* the update stream.
|
|
16
|
+
* 3. Tracks `inflightDispatches` per agentId. A second envelope arriving
|
|
17
|
+
* while the same agent is already processing a dispatch is rejected
|
|
18
|
+
* with `recipient_busy` so the orchestrator can retry against another
|
|
19
|
+
* agent (or fall back to fresh-spawn). Reject is **dispatch-scoped**
|
|
20
|
+
* — non-dispatch work on the agent (peer messages, user chat) does
|
|
21
|
+
* NOT trigger the busy reject; that work stacks naturally.
|
|
22
|
+
*
|
|
23
|
+
* Reply path: captures `args.summary` from the done() tool call's rawInput
|
|
24
|
+
* directly off the update stream, so it works for both parented and
|
|
25
|
+
* parentless target agents (the parented branch in `handlers-v2` does NOT
|
|
26
|
+
* stash `_lastSummary` — only parentless agents do — but we don't need
|
|
27
|
+
* that path because we observe done() in-stream).
|
|
28
|
+
*
|
|
29
|
+
* @module dispatch/mail-inbound-reuse-consumer
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { AgentManager } from "../agent/agent-manager.js";
|
|
33
|
+
import type { AgentStore } from "../agent/agent-store.js";
|
|
34
|
+
import type { ExtendedSessionUpdate } from "acp-factory";
|
|
35
|
+
import type {
|
|
36
|
+
InboxEvents,
|
|
37
|
+
InboxMessageEvent,
|
|
38
|
+
MailInboundSidecar,
|
|
39
|
+
} from "./mail-inbound-consumer.js";
|
|
40
|
+
import {
|
|
41
|
+
collapsePermissionsForAutonomous,
|
|
42
|
+
type WireLoadout,
|
|
43
|
+
} from "./loadout-translation.js";
|
|
44
|
+
import {
|
|
45
|
+
setPermissionOverlay,
|
|
46
|
+
clearPermissionOverlay,
|
|
47
|
+
} from "./permission-overlay.js";
|
|
48
|
+
|
|
49
|
+
export interface MailInboundReuseConsumerOptions {
|
|
50
|
+
/**
|
|
51
|
+
* The sidecar agent ID. Envelopes addressed to this id are handled by
|
|
52
|
+
* the original `mail-inbound-consumer` (fresh-spawn path); the reuse
|
|
53
|
+
* consumer ignores them so the two consumers' filters don't overlap.
|
|
54
|
+
*/
|
|
55
|
+
dispatcherAgentId: string;
|
|
56
|
+
|
|
57
|
+
/** Raw inbox event emitter (from inboxAdapter.getInbox().events). */
|
|
58
|
+
inboxEvents: InboxEvents;
|
|
59
|
+
|
|
60
|
+
/** Agent lifecycle manager — used to drive the existing session. */
|
|
61
|
+
agentManager: AgentManager;
|
|
62
|
+
|
|
63
|
+
/** Agent store — used to confirm the target agent is running. */
|
|
64
|
+
agentStore: AgentStore;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Optional sidecar reference. Populated after step 13 in boot-v2 via
|
|
68
|
+
* the shared systemRef. The consumer accesses it lazily at reply time.
|
|
69
|
+
*/
|
|
70
|
+
getSidecar: () => MailInboundSidecar | null | undefined;
|
|
71
|
+
|
|
72
|
+
/** Optional logger (default: console.log). */
|
|
73
|
+
log?: (msg: string) => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MailInboundReuseConsumerStats {
|
|
77
|
+
/** Count of envelopes dropped because they lacked a taskId. */
|
|
78
|
+
droppedMalformed: number;
|
|
79
|
+
/** Number of distinct taskIds currently tracked for dedup. */
|
|
80
|
+
seenTaskIds: number;
|
|
81
|
+
/** Number of rejects emitted because the target agent was already busy with a dispatch. */
|
|
82
|
+
busyRejects: number;
|
|
83
|
+
/** Currently in-flight dispatches keyed by agentId. */
|
|
84
|
+
inflightCount: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MailInboundReuseConsumer {
|
|
88
|
+
stop(): void;
|
|
89
|
+
stats(): MailInboundReuseConsumerStats;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface InflightDispatch {
|
|
93
|
+
dispatchId: string;
|
|
94
|
+
conversationId: string | null;
|
|
95
|
+
startedAt: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const SEEN_TASK_TTL_MS = 60 * 60 * 1000;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wire the mail-inbound reuse consumer.
|
|
102
|
+
*
|
|
103
|
+
* Returns a `stop()` handle that detaches the inbox listener.
|
|
104
|
+
*/
|
|
105
|
+
export function createMailInboundReuseConsumer(
|
|
106
|
+
opts: MailInboundReuseConsumerOptions,
|
|
107
|
+
): MailInboundReuseConsumer {
|
|
108
|
+
const {
|
|
109
|
+
dispatcherAgentId,
|
|
110
|
+
inboxEvents,
|
|
111
|
+
agentManager,
|
|
112
|
+
agentStore,
|
|
113
|
+
getSidecar,
|
|
114
|
+
log = (msg: string) => console.log(msg),
|
|
115
|
+
} = opts;
|
|
116
|
+
|
|
117
|
+
// agentId → inflight dispatch state. Used both to gate concurrent
|
|
118
|
+
// dispatches against the same agent and to look up the conversation
|
|
119
|
+
// when posting the reply.
|
|
120
|
+
const inflightDispatches = new Map<string, InflightDispatch>();
|
|
121
|
+
|
|
122
|
+
// taskId → expiresAt: idempotency guard mirroring mail-inbound-consumer.
|
|
123
|
+
const seenTaskIds = new Map<string, number>();
|
|
124
|
+
function pruneSeenTaskIds(): void {
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const [id, expiresAt] of seenTaskIds) {
|
|
127
|
+
if (expiresAt <= now) seenTaskIds.delete(id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let droppedMalformedCount = 0;
|
|
132
|
+
let busyRejectCount = 0;
|
|
133
|
+
|
|
134
|
+
log(
|
|
135
|
+
`[mail-inbound-reuse] Consumer ready — listening for x-dispatch/work envelopes ` +
|
|
136
|
+
`addressed to non-sidecar agents (sidecar=${dispatcherAgentId})`,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const onMessage = (event: InboxMessageEvent): void => {
|
|
140
|
+
// Only handle envelopes addressed to NON-sidecar agents. Sidecar
|
|
141
|
+
// envelopes are owned by mail-inbound-consumer (fresh-spawn).
|
|
142
|
+
if (event.agentId === dispatcherAgentId) return;
|
|
143
|
+
|
|
144
|
+
const content = event.message?.content as
|
|
145
|
+
| {
|
|
146
|
+
schema?: string;
|
|
147
|
+
data?: {
|
|
148
|
+
taskId?: string;
|
|
149
|
+
prompt?: string;
|
|
150
|
+
content?: string;
|
|
151
|
+
role?: string;
|
|
152
|
+
tags?: string[];
|
|
153
|
+
loadout?: WireLoadout;
|
|
154
|
+
metadata?: Record<string, unknown>;
|
|
155
|
+
};
|
|
156
|
+
_conversationId?: string;
|
|
157
|
+
}
|
|
158
|
+
| undefined;
|
|
159
|
+
|
|
160
|
+
if (content?.schema !== "x-dispatch/work") return;
|
|
161
|
+
|
|
162
|
+
const data = content.data;
|
|
163
|
+
if (!data?.taskId) {
|
|
164
|
+
droppedMalformedCount++;
|
|
165
|
+
log(
|
|
166
|
+
`[mail-inbound-reuse] Dropping malformed envelope (no taskId, total=${droppedMalformedCount})`,
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const taskId = data.taskId;
|
|
172
|
+
pruneSeenTaskIds();
|
|
173
|
+
const seenExpiresAt = seenTaskIds.get(taskId);
|
|
174
|
+
if (seenExpiresAt !== undefined && seenExpiresAt > Date.now()) {
|
|
175
|
+
// Re-delivery within dedup window — silently drop.
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
seenTaskIds.set(taskId, Date.now() + SEEN_TASK_TTL_MS);
|
|
179
|
+
|
|
180
|
+
const targetAgentId = event.agentId;
|
|
181
|
+
const conversationId = content._conversationId ?? null;
|
|
182
|
+
const prompt = data.prompt ?? data.content ?? "";
|
|
183
|
+
|
|
184
|
+
// Resolve target — must be a known, non-stopped agent.
|
|
185
|
+
const targetRecord = agentStore.getAgent(targetAgentId);
|
|
186
|
+
if (!targetRecord) {
|
|
187
|
+
log(
|
|
188
|
+
`[mail-inbound-reuse] Unknown target agent ${targetAgentId} for taskId=${taskId} — dropping`,
|
|
189
|
+
);
|
|
190
|
+
void postReplyTurn(conversationId, targetAgentId, {
|
|
191
|
+
status: "agent_unavailable",
|
|
192
|
+
reason: `Agent ${targetAgentId} not registered on this swarm`,
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (targetRecord.state === "stopped" || targetRecord.state === "failed") {
|
|
197
|
+
log(
|
|
198
|
+
`[mail-inbound-reuse] Target agent ${targetAgentId} state=${targetRecord.state} — dropping taskId=${taskId}`,
|
|
199
|
+
);
|
|
200
|
+
void postReplyTurn(conversationId, targetAgentId, {
|
|
201
|
+
status: "agent_unavailable",
|
|
202
|
+
reason: `Agent ${targetAgentId} state=${targetRecord.state}`,
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// In-flight check — only reject when this same agent is already
|
|
208
|
+
// processing another tracked dispatch. Non-dispatch work (peer chat,
|
|
209
|
+
// user prompts) does not block; promptUntilDone-style serial stacking
|
|
210
|
+
// handles that.
|
|
211
|
+
const existing = inflightDispatches.get(targetAgentId);
|
|
212
|
+
if (existing) {
|
|
213
|
+
busyRejectCount++;
|
|
214
|
+
log(
|
|
215
|
+
`[mail-inbound-reuse] recipient_busy — agent=${targetAgentId} already processing ` +
|
|
216
|
+
`dispatch=${existing.dispatchId}; rejecting taskId=${taskId}`,
|
|
217
|
+
);
|
|
218
|
+
void postReplyTurn(conversationId, targetAgentId, {
|
|
219
|
+
status: "recipient_busy",
|
|
220
|
+
reason: `Agent ${targetAgentId} is processing dispatch ${existing.dispatchId}`,
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
log(
|
|
226
|
+
`[mail-inbound-reuse] Driving dispatch taskId=${taskId} on existing agent=${targetAgentId} ` +
|
|
227
|
+
`conv=${conversationId ?? "(none)"}`,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
inflightDispatches.set(targetAgentId, {
|
|
231
|
+
dispatchId: taskId,
|
|
232
|
+
conversationId,
|
|
233
|
+
startedAt: Date.now(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Drive the agent's existing session via raw `prompt()` rather than
|
|
237
|
+
// `promptUntilDone` because the latter auto-terminates the agent on
|
|
238
|
+
// done() — fatal for long-lived workers we want to reuse. We watch
|
|
239
|
+
// the update stream ourselves for the done() tool call and capture
|
|
240
|
+
// the summary inline.
|
|
241
|
+
void driveDispatch(
|
|
242
|
+
targetAgentId,
|
|
243
|
+
taskId,
|
|
244
|
+
prompt,
|
|
245
|
+
conversationId,
|
|
246
|
+
data.loadout,
|
|
247
|
+
).finally(() => {
|
|
248
|
+
inflightDispatches.delete(targetAgentId);
|
|
249
|
+
// Always clear the permission overlay, even if driveDispatch
|
|
250
|
+
// didn't set one — keeps the registry tidy and defends against
|
|
251
|
+
// a future code path that sets one but skips its own cleanup.
|
|
252
|
+
clearPermissionOverlay(targetAgentId);
|
|
253
|
+
});
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
async function driveDispatch(
|
|
257
|
+
targetAgentId: string,
|
|
258
|
+
taskId: string,
|
|
259
|
+
prompt: string,
|
|
260
|
+
conversationId: string | null,
|
|
261
|
+
loadout: WireLoadout | undefined,
|
|
262
|
+
): Promise<void> {
|
|
263
|
+
// Apply the dispatch's loadout permissions as a runtime overlay
|
|
264
|
+
// for the duration of this prompt drive. The PreToolUse hook
|
|
265
|
+
// installed at spawn-time consults the overlay registry per tool
|
|
266
|
+
// call and denies calls that match the loadout's deny rules.
|
|
267
|
+
// `fullAutonomous: true` because mail-inbound workers have no
|
|
268
|
+
// human in the loop — `ask` rules collapse to `allow`. Cleared
|
|
269
|
+
// unconditionally in `finally` so a crash mid-prompt doesn't
|
|
270
|
+
// leave a stale overlay on the agent.
|
|
271
|
+
const overlay = collapsePermissionsForAutonomous(
|
|
272
|
+
loadout?.permissions,
|
|
273
|
+
/* fullAutonomous */ true,
|
|
274
|
+
);
|
|
275
|
+
if (overlay) {
|
|
276
|
+
setPermissionOverlay(targetAgentId, overlay);
|
|
277
|
+
log(
|
|
278
|
+
`[mail-inbound-reuse] Applied permission overlay for agent=${targetAgentId} ` +
|
|
279
|
+
`taskId=${taskId} (deny=${overlay.deny.length} allow=${overlay.allow.length})`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let summary: string | undefined;
|
|
284
|
+
let status: string | undefined;
|
|
285
|
+
let doneSeen = false;
|
|
286
|
+
let promptError: Error | undefined;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
for await (const update of agentManager.prompt(targetAgentId, prompt)) {
|
|
290
|
+
const captured = captureDoneCall(update);
|
|
291
|
+
if (captured) {
|
|
292
|
+
doneSeen = true;
|
|
293
|
+
if (captured.summary) summary = captured.summary;
|
|
294
|
+
if (captured.status) status = captured.status;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
promptError = err as Error;
|
|
299
|
+
log(
|
|
300
|
+
`[mail-inbound-reuse] prompt() threw for agent=${targetAgentId} taskId=${taskId}: ` +
|
|
301
|
+
`${promptError.message ?? String(promptError)}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Fallback: read `_lastSummary` from agentStore. The done() handler
|
|
306
|
+
// persists this for in-flight agents (Phase 2C) so the reply path
|
|
307
|
+
// is reliable even when the prompt iterator's update stream raced
|
|
308
|
+
// the ACP connection close. Covers:
|
|
309
|
+
// - prompt() threw before yielding the done() update (catch above)
|
|
310
|
+
// - inline capture saw done() but `args.summary` was empty
|
|
311
|
+
// - iterator yielded but our captureDoneCall missed (shape drift)
|
|
312
|
+
if (!summary) {
|
|
313
|
+
try {
|
|
314
|
+
const record = agentStore.getAgent(targetAgentId);
|
|
315
|
+
const fallback = record?.metadata?._lastSummary;
|
|
316
|
+
if (typeof fallback === "string" && fallback.length > 0) {
|
|
317
|
+
summary = fallback;
|
|
318
|
+
doneSeen = true;
|
|
319
|
+
log(
|
|
320
|
+
`[mail-inbound-reuse] Recovered summary from _lastSummary fallback for agent=${targetAgentId} taskId=${taskId}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
/* best effort — store may be closing during shutdown */
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Post reply: prefer real summary, fall back to status notes when
|
|
329
|
+
// we genuinely have nothing.
|
|
330
|
+
if (summary) {
|
|
331
|
+
void postReplyTurn(conversationId, targetAgentId, summary).then(() => {
|
|
332
|
+
// Clear the persisted summary so it doesn't replay if the same
|
|
333
|
+
// agentId is dispatched again. Best-effort.
|
|
334
|
+
try {
|
|
335
|
+
const existing = agentStore.getAgent(targetAgentId)?.metadata ?? {};
|
|
336
|
+
const { _lastSummary: _drop, ...rest } = existing as Record<string, unknown>;
|
|
337
|
+
void _drop;
|
|
338
|
+
agentStore.updateAgent(targetAgentId, { metadata: rest });
|
|
339
|
+
} catch {
|
|
340
|
+
/* best effort */
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (promptError) {
|
|
347
|
+
void postReplyTurn(conversationId, targetAgentId, {
|
|
348
|
+
status: "failed",
|
|
349
|
+
reason: `Prompt failed: ${promptError.message ?? String(promptError)}`,
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (!doneSeen) {
|
|
355
|
+
log(
|
|
356
|
+
`[mail-inbound-reuse] Agent ${targetAgentId} finished prompt without calling done() ` +
|
|
357
|
+
`for taskId=${taskId} — posting "incomplete" reply`,
|
|
358
|
+
);
|
|
359
|
+
void postReplyTurn(conversationId, targetAgentId, {
|
|
360
|
+
status: "incomplete",
|
|
361
|
+
reason: "Agent did not call done() within the prompt cycle",
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
void postReplyTurn(
|
|
367
|
+
conversationId,
|
|
368
|
+
targetAgentId,
|
|
369
|
+
`Dispatch ${taskId} ${status ?? "completed"} (no summary)`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Detect a `done()` tool-call update and extract `{ status, summary }`
|
|
375
|
+
* from rawInput. Mirrors `promptUntilDone`'s detection logic but also
|
|
376
|
+
* captures `summary` (which the AgentManager's loop discards).
|
|
377
|
+
*/
|
|
378
|
+
function captureDoneCall(
|
|
379
|
+
update: ExtendedSessionUpdate,
|
|
380
|
+
): { status?: string; summary?: string } | null {
|
|
381
|
+
const u = update as unknown as Record<string, unknown>;
|
|
382
|
+
const sessionUpdate = u.sessionUpdate;
|
|
383
|
+
const title = u.title;
|
|
384
|
+
|
|
385
|
+
const isDoneToolCall =
|
|
386
|
+
(sessionUpdate === "tool_call" || sessionUpdate === "tool_call_update") &&
|
|
387
|
+
typeof title === "string" &&
|
|
388
|
+
title.endsWith("__done");
|
|
389
|
+
|
|
390
|
+
if (!isDoneToolCall) {
|
|
391
|
+
// Older fallback shape.
|
|
392
|
+
if (
|
|
393
|
+
u.type === "result" &&
|
|
394
|
+
u.subtype === "tool_result" &&
|
|
395
|
+
u.toolName === "done"
|
|
396
|
+
) {
|
|
397
|
+
const result = u.result as { status?: string; summary?: string } | undefined;
|
|
398
|
+
if (result) {
|
|
399
|
+
return { status: result.status, summary: result.summary };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let input: { status?: string; summary?: string } | undefined;
|
|
406
|
+
try {
|
|
407
|
+
const raw = u.rawInput;
|
|
408
|
+
if (typeof raw === "string") {
|
|
409
|
+
input = JSON.parse(raw) as { status?: string; summary?: string };
|
|
410
|
+
} else if (raw && typeof raw === "object") {
|
|
411
|
+
input = raw as { status?: string; summary?: string };
|
|
412
|
+
} else if (u.input && typeof u.input === "object") {
|
|
413
|
+
input = u.input as { status?: string; summary?: string };
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// rawInput not yet parseable (multi-update tool call); ignore.
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!input) return null;
|
|
420
|
+
return { status: input.status, summary: input.summary };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function postReplyTurn(
|
|
424
|
+
conversationId: string | null,
|
|
425
|
+
fromAgentId: string,
|
|
426
|
+
content: string | { status: string; reason: string },
|
|
427
|
+
): Promise<void> {
|
|
428
|
+
if (!conversationId) {
|
|
429
|
+
log(
|
|
430
|
+
`[mail-inbound-reuse] No conversationId — reply for ${fromAgentId} dropped: ` +
|
|
431
|
+
`${typeof content === "string" ? content.slice(0, 80) : content.status}`,
|
|
432
|
+
);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const sidecar = getSidecar();
|
|
436
|
+
if (!sidecar?.postMailTurn) {
|
|
437
|
+
log(`[mail-inbound-reuse] No sidecar/postMailTurn — reply turn dropped`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const body = typeof content === "string" ? content : JSON.stringify(content);
|
|
441
|
+
try {
|
|
442
|
+
await sidecar.postMailTurn(conversationId, fromAgentId, body);
|
|
443
|
+
} catch (err) {
|
|
444
|
+
log(
|
|
445
|
+
`[mail-inbound-reuse] postMailTurn failed for ${fromAgentId}: ` +
|
|
446
|
+
`${(err as Error).message ?? String(err)}`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
inboxEvents.on("inbox.message", onMessage);
|
|
452
|
+
|
|
453
|
+
let stopped = false;
|
|
454
|
+
return {
|
|
455
|
+
stop() {
|
|
456
|
+
if (stopped) return;
|
|
457
|
+
stopped = true;
|
|
458
|
+
try {
|
|
459
|
+
if (inboxEvents.off) {
|
|
460
|
+
inboxEvents.off("inbox.message", onMessage);
|
|
461
|
+
} else if (inboxEvents.removeListener) {
|
|
462
|
+
inboxEvents.removeListener("inbox.message", onMessage);
|
|
463
|
+
}
|
|
464
|
+
} catch {
|
|
465
|
+
// best effort
|
|
466
|
+
}
|
|
467
|
+
log(`[mail-inbound-reuse] Consumer stopped`);
|
|
468
|
+
},
|
|
469
|
+
stats() {
|
|
470
|
+
pruneSeenTaskIds();
|
|
471
|
+
return {
|
|
472
|
+
droppedMalformed: droppedMalformedCount,
|
|
473
|
+
seenTaskIds: seenTaskIds.size,
|
|
474
|
+
busyRejects: busyRejectCount,
|
|
475
|
+
inflightCount: inflightDispatches.size,
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission Evaluator
|
|
3
|
+
*
|
|
4
|
+
* Pure function: given a tool call (name + input) and an overlay's
|
|
5
|
+
* permission rules, decide whether to deny, allow, or pass-through.
|
|
6
|
+
* Used by the `PreToolUse` hook installed at spawn time to enforce
|
|
7
|
+
* dispatch-supplied loadout permissions on a running session.
|
|
8
|
+
*
|
|
9
|
+
* Rule format (matches Claude Agent SDK convention):
|
|
10
|
+
*
|
|
11
|
+
* <ToolName> — match any call to this tool
|
|
12
|
+
* <ToolName>(<glob-pattern>) — match calls whose primary input
|
|
13
|
+
* field matches the glob pattern
|
|
14
|
+
*
|
|
15
|
+
* The "primary input field" is tool-specific:
|
|
16
|
+
*
|
|
17
|
+
* Bash → input.command
|
|
18
|
+
* Read → input.file_path
|
|
19
|
+
* Write → input.file_path
|
|
20
|
+
* Edit → input.file_path
|
|
21
|
+
* Grep → input.pattern
|
|
22
|
+
* <other> → no field-level match; rule must be bare `<ToolName>`
|
|
23
|
+
*
|
|
24
|
+
* Glob: `*` matches any sequence of characters (no path-segment
|
|
25
|
+
* distinction). Other regex specials are escaped.
|
|
26
|
+
*
|
|
27
|
+
* Decision precedence:
|
|
28
|
+
*
|
|
29
|
+
* 1. If any rule in `deny` matches → 'deny'
|
|
30
|
+
* 2. Else if any rule in `allow` matches → 'allow'
|
|
31
|
+
* 3. Else → 'pass-through' (let the session's static rules decide)
|
|
32
|
+
*
|
|
33
|
+
* `ask` rules are not evaluated here — the consumer is expected to
|
|
34
|
+
* collapse `ask` to either `allow` or `deny` before setting the
|
|
35
|
+
* overlay (via `collapsePermissionsForAutonomous` based on the
|
|
36
|
+
* spawn's `fullAutonomous` flag). The evaluator sees only collapsed
|
|
37
|
+
* `allow` and `deny` lists.
|
|
38
|
+
*
|
|
39
|
+
* @module dispatch/permission-evaluator
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { OverlayPermissions } from "./permission-overlay.js";
|
|
43
|
+
|
|
44
|
+
export interface PermissionDecision {
|
|
45
|
+
decision: "allow" | "deny" | "pass-through";
|
|
46
|
+
matchedRule?: string;
|
|
47
|
+
matchedField?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Tool-name → primary input field name. Add entries as needed for
|
|
52
|
+
* additional tools. Tools not listed have no field-level matching;
|
|
53
|
+
* only bare `<ToolName>` rules apply.
|
|
54
|
+
*/
|
|
55
|
+
const PRIMARY_INPUT_FIELD: Record<string, string> = {
|
|
56
|
+
Bash: "command",
|
|
57
|
+
Read: "file_path",
|
|
58
|
+
Write: "file_path",
|
|
59
|
+
Edit: "file_path",
|
|
60
|
+
MultiEdit: "file_path",
|
|
61
|
+
Grep: "pattern",
|
|
62
|
+
Glob: "pattern",
|
|
63
|
+
NotebookRead: "notebook_path",
|
|
64
|
+
NotebookEdit: "notebook_path",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Evaluate a single tool call against an overlay's permission rules.
|
|
69
|
+
*
|
|
70
|
+
* Returns `'pass-through'` (the default) when no rule matches — the
|
|
71
|
+
* caller should fall back to the session's static permission rules
|
|
72
|
+
* for the final decision.
|
|
73
|
+
*/
|
|
74
|
+
export function evaluatePermission(
|
|
75
|
+
toolName: string,
|
|
76
|
+
toolInput: unknown,
|
|
77
|
+
overlay: OverlayPermissions,
|
|
78
|
+
): PermissionDecision {
|
|
79
|
+
// Deny rules win over allow.
|
|
80
|
+
for (const rule of overlay.deny ?? []) {
|
|
81
|
+
const match = matchRule(rule, toolName, toolInput);
|
|
82
|
+
if (match.matched) {
|
|
83
|
+
return {
|
|
84
|
+
decision: "deny",
|
|
85
|
+
matchedRule: rule,
|
|
86
|
+
...(match.field ? { matchedField: match.field } : {}),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const rule of overlay.allow ?? []) {
|
|
92
|
+
const match = matchRule(rule, toolName, toolInput);
|
|
93
|
+
if (match.matched) {
|
|
94
|
+
return {
|
|
95
|
+
decision: "allow",
|
|
96
|
+
matchedRule: rule,
|
|
97
|
+
...(match.field ? { matchedField: match.field } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { decision: "pass-through" };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface RuleMatch {
|
|
106
|
+
matched: boolean;
|
|
107
|
+
field?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Test a single rule against a tool call. Returns whether the rule
|
|
112
|
+
* matched and which input field (if any) was tested.
|
|
113
|
+
*
|
|
114
|
+
* Exported only for testability; production callers should use
|
|
115
|
+
* `evaluatePermission`.
|
|
116
|
+
*/
|
|
117
|
+
export function matchRule(
|
|
118
|
+
rule: string,
|
|
119
|
+
toolName: string,
|
|
120
|
+
toolInput: unknown,
|
|
121
|
+
): RuleMatch {
|
|
122
|
+
const parsed = parseRule(rule);
|
|
123
|
+
if (!parsed) return { matched: false };
|
|
124
|
+
if (parsed.toolName !== toolName) return { matched: false };
|
|
125
|
+
|
|
126
|
+
// No pattern → any call to this tool matches.
|
|
127
|
+
if (parsed.pattern === undefined) return { matched: true };
|
|
128
|
+
|
|
129
|
+
// Empty pattern (`Bash()`) — also any call to this tool. Conservative.
|
|
130
|
+
if (parsed.pattern === "") return { matched: true };
|
|
131
|
+
|
|
132
|
+
const fieldName = PRIMARY_INPUT_FIELD[toolName];
|
|
133
|
+
if (!fieldName) {
|
|
134
|
+
// No primary field defined for this tool → can't match a pattern.
|
|
135
|
+
// Pattern-bearing rules for unknown tools never match (skip).
|
|
136
|
+
return { matched: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fieldValue = readField(toolInput, fieldName);
|
|
140
|
+
if (typeof fieldValue !== "string") {
|
|
141
|
+
return { matched: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const re = globToRegex(parsed.pattern);
|
|
145
|
+
return {
|
|
146
|
+
matched: re.test(fieldValue),
|
|
147
|
+
field: fieldName,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface ParsedRule {
|
|
152
|
+
toolName: string;
|
|
153
|
+
/** undefined → bare `<ToolName>` rule (no parentheses) */
|
|
154
|
+
pattern?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseRule(rule: string): ParsedRule | null {
|
|
158
|
+
// Tool names allow letters/digits/underscores AND hyphens — MCP tools use
|
|
159
|
+
// hyphens in their server prefix (e.g., `mcp__agent-inbox__list_agents`).
|
|
160
|
+
const m = rule.match(/^([A-Za-z_][A-Za-z0-9_-]*)(?:\((.*)\))?$/);
|
|
161
|
+
if (!m) return null;
|
|
162
|
+
const [, toolName, pattern] = m;
|
|
163
|
+
if (pattern === undefined) {
|
|
164
|
+
return { toolName: toolName as string };
|
|
165
|
+
}
|
|
166
|
+
return { toolName: toolName as string, pattern };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function readField(input: unknown, field: string): unknown {
|
|
170
|
+
if (!input || typeof input !== "object") return undefined;
|
|
171
|
+
return (input as Record<string, unknown>)[field];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Convert a Claude permission glob into a regex. Only `*` is special;
|
|
176
|
+
* everything else is treated as a literal. The result is anchored
|
|
177
|
+
* (`^...$`) for whole-string matching.
|
|
178
|
+
*/
|
|
179
|
+
function globToRegex(pattern: string): RegExp {
|
|
180
|
+
let out = "^";
|
|
181
|
+
for (const ch of pattern) {
|
|
182
|
+
if (ch === "*") {
|
|
183
|
+
out += ".*";
|
|
184
|
+
} else {
|
|
185
|
+
// Escape regex specials.
|
|
186
|
+
out += ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
out += "$";
|
|
190
|
+
return new RegExp(out);
|
|
191
|
+
}
|