macro-agent 0.2.4 → 0.2.5
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 +182 -43
- package/dist/agent/agent-manager-v2.js.map +1 -1
- package/dist/agent/agent-manager.d.ts +17 -0
- package/dist/agent/agent-manager.d.ts.map +1 -1
- package/dist/agent/agent-manager.js.map +1 -1
- package/dist/agent/types.d.ts +21 -2
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/cognitive/macro-agent-backend.d.ts.map +1 -1
- package/dist/cognitive/macro-agent-backend.js +37 -8
- package/dist/cognitive/macro-agent-backend.js.map +1 -1
- package/dist/cognitive/types.d.ts +24 -0
- package/dist/cognitive/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/map/mail-bridge.d.ts +7 -0
- package/dist/map/mail-bridge.d.ts.map +1 -1
- package/dist/map/mail-bridge.js +39 -9
- package/dist/map/mail-bridge.js.map +1 -1
- package/dist/teams/team-runtime-v2.d.ts.map +1 -1
- package/dist/teams/team-runtime-v2.js +23 -6
- package/dist/teams/team-runtime-v2.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/__tests__/agent-manager-v2.completion-signal.test.ts +284 -0
- package/src/agent/agent-manager-v2.ts +216 -50
- package/src/agent/agent-manager.ts +17 -0
- package/src/agent/types.ts +23 -2
- package/src/cognitive/__tests__/macro-agent-backend.test.ts +158 -5
- package/src/cognitive/macro-agent-backend.ts +39 -7
- package/src/cognitive/types.ts +24 -0
- package/src/index.ts +2 -0
- package/src/map/__tests__/mail-bridge.test.ts +39 -2
- package/src/map/mail-bridge.ts +61 -13
- package/src/teams/team-runtime-v2.ts +29 -6
- 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,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S1 (completion-detection) unit tests for promptUntilDone's optional
|
|
3
|
+
* `isComplete` predicate.
|
|
4
|
+
*
|
|
5
|
+
* The macro `promptUntilDone` normally completes ONLY when the agent calls the
|
|
6
|
+
* macro done() tool; otherwise it re-prompts "call done()" up to maxFollowUps
|
|
7
|
+
* times (each a slow full turn). The τ agent calls τ's `finish`, not macro
|
|
8
|
+
* done(), so without an external completion predicate the loop exhausts
|
|
9
|
+
* maxFollowUps and Atlas.solve() hangs until the rollout backstop.
|
|
10
|
+
*
|
|
11
|
+
* These tests drive promptUntilDone with a MOCKED acp session `prompt` stream
|
|
12
|
+
* (no live agent) and assert:
|
|
13
|
+
* - an `isComplete` that flips true after the first streamed update breaks the
|
|
14
|
+
* loop on attempt 0, calls terminate(agentId, "completed"), returns
|
|
15
|
+
* completedExternally:true, and does NOT exhaust maxFollowUps;
|
|
16
|
+
* - back-compat: with NO predicate the existing done()-detection is unchanged.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
20
|
+
import { createAgentManagerV2 } from "../agent-manager-v2.js";
|
|
21
|
+
import { AgentStore } from "../agent-store.js";
|
|
22
|
+
import type { AgentManager } from "../agent-manager.js";
|
|
23
|
+
import type { InboxAdapter, TasksAdapter } from "../../adapters/types.js";
|
|
24
|
+
|
|
25
|
+
// ── Mock acp-factory: a controllable per-spawn session.prompt stream ───────
|
|
26
|
+
// `promptStreamFactory` is reassigned per test to control what each prompt
|
|
27
|
+
// attempt yields. `promptCallCount` lets us assert how many attempts ran.
|
|
28
|
+
let promptStreamFactory: () => AsyncIterable<unknown>;
|
|
29
|
+
let promptCallCount = 0;
|
|
30
|
+
// Invoked when the agent handle is closed (i.e. terminate() tears down the
|
|
31
|
+
// session). The silent-stream repro registers a hook here so that ONLY a real
|
|
32
|
+
// terminate() can end its otherwise-forever-blocked stream — faithfully
|
|
33
|
+
// modelling the live "stream ends/throws on teardown" behavior. On the old
|
|
34
|
+
// reactive-only code terminate() is never called mid-stream, so the stream
|
|
35
|
+
// never ends → the for-await hangs (red).
|
|
36
|
+
let onHandleClose: (() => void) | null = null;
|
|
37
|
+
|
|
38
|
+
vi.mock("acp-factory", () => ({
|
|
39
|
+
AgentFactory: {
|
|
40
|
+
spawn: vi.fn().mockResolvedValue({
|
|
41
|
+
createSession: vi.fn().mockResolvedValue({
|
|
42
|
+
id: "provider-session-1",
|
|
43
|
+
prompt: vi.fn().mockImplementation(() => {
|
|
44
|
+
promptCallCount++;
|
|
45
|
+
return promptStreamFactory();
|
|
46
|
+
}),
|
|
47
|
+
forkWithFlush: vi.fn().mockResolvedValue({ id: "forked-session-1" }),
|
|
48
|
+
}),
|
|
49
|
+
loadSession: vi.fn().mockResolvedValue({ id: "loaded-session-1" }),
|
|
50
|
+
close: vi.fn().mockImplementation(async () => {
|
|
51
|
+
onHandleClose?.();
|
|
52
|
+
}),
|
|
53
|
+
isRunning: vi.fn().mockReturnValue(true),
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
function createMockInboxAdapter(): InboxAdapter {
|
|
59
|
+
return {
|
|
60
|
+
registerAgent: vi.fn().mockResolvedValue(undefined),
|
|
61
|
+
deregisterAgent: vi.fn().mockResolvedValue(undefined),
|
|
62
|
+
send: vi.fn().mockResolvedValue("msg-1"),
|
|
63
|
+
onDelivery: vi.fn(),
|
|
64
|
+
offDelivery: vi.fn(),
|
|
65
|
+
checkInbox: vi.fn().mockResolvedValue([]),
|
|
66
|
+
readThread: vi.fn().mockResolvedValue([]),
|
|
67
|
+
setSignalFilter: vi.fn(),
|
|
68
|
+
setEmissionValidator: vi.fn(),
|
|
69
|
+
socketPath: "/tmp/test-inbox.sock",
|
|
70
|
+
stop: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
} as unknown as InboxAdapter;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A single benign streamed update (an agent message chunk — NOT a done() tool
|
|
75
|
+
// call), used to trigger the isComplete check mid-stream.
|
|
76
|
+
function* oneMessageUpdate(): Generator<unknown> {
|
|
77
|
+
yield {
|
|
78
|
+
sessionUpdate: "agent_message_chunk",
|
|
79
|
+
content: { type: "text", text: "working" },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A stream that emits a macro done() tool-call update (title ends with __done),
|
|
84
|
+
// for the back-compat path.
|
|
85
|
+
function* doneToolUpdate(): Generator<unknown> {
|
|
86
|
+
yield {
|
|
87
|
+
sessionUpdate: "tool_call",
|
|
88
|
+
title: "mcp__macro-agent__done",
|
|
89
|
+
rawInput: { status: "completed" },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function* asAsync(gen: Iterable<unknown>): AsyncIterable<unknown> {
|
|
94
|
+
for (const u of gen) yield u;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reproduces the live τ hang: the agent runs the whole episode in ONE prompt()
|
|
98
|
+
// call, yields a couple of updates, then the stream goes SILENT but does NOT
|
|
99
|
+
// close — the async iterator just blocks forever. With only the reactive
|
|
100
|
+
// per-update / per-attempt checks, isComplete is never re-evaluated and the
|
|
101
|
+
// for-await never unblocks.
|
|
102
|
+
//
|
|
103
|
+
// The ONLY way out is terminate(): it tears down the session (calls
|
|
104
|
+
// handle.close()), which we wire to release the dangling await — faithfully
|
|
105
|
+
// modelling the live "stream ends on teardown" behavior. So on the old
|
|
106
|
+
// reactive-only code (terminate never called mid-stream) the stream NEVER ends
|
|
107
|
+
// → hang (red); the concurrent poller calls terminate → close → release →
|
|
108
|
+
// stream ends → completion (green).
|
|
109
|
+
function silentThenHangStream(): () => AsyncIterable<unknown> {
|
|
110
|
+
let release: () => void = () => {};
|
|
111
|
+
const hang = new Promise<void>((resolve) => {
|
|
112
|
+
release = resolve;
|
|
113
|
+
});
|
|
114
|
+
// terminate() → handle.close() → release the blocked stream.
|
|
115
|
+
onHandleClose = () => release();
|
|
116
|
+
async function* gen(): AsyncIterable<unknown> {
|
|
117
|
+
yield {
|
|
118
|
+
sessionUpdate: "agent_message_chunk",
|
|
119
|
+
content: { type: "text", text: "working 1" },
|
|
120
|
+
};
|
|
121
|
+
yield {
|
|
122
|
+
sessionUpdate: "agent_message_chunk",
|
|
123
|
+
content: { type: "text", text: "working 2" },
|
|
124
|
+
};
|
|
125
|
+
// Silent-but-open: block forever until terminate() releases it — exactly
|
|
126
|
+
// the live failure mode (silent, non-closing iterator).
|
|
127
|
+
await hang;
|
|
128
|
+
}
|
|
129
|
+
return gen;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function createMockTasksAdapter(): TasksAdapter {
|
|
133
|
+
return {
|
|
134
|
+
createTask: vi.fn().mockResolvedValue("ot-task-1"),
|
|
135
|
+
assignTask: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
transitionTask: vi.fn().mockResolvedValue(undefined),
|
|
137
|
+
getTask: vi.fn().mockResolvedValue(null),
|
|
138
|
+
queryReady: vi.fn().mockResolvedValue([]),
|
|
139
|
+
listTasks: vi.fn().mockResolvedValue([]),
|
|
140
|
+
addBlocker: vi.fn().mockResolvedValue(undefined),
|
|
141
|
+
removeBlocker: vi.fn().mockResolvedValue(undefined),
|
|
142
|
+
claimTask: vi.fn().mockResolvedValue(null),
|
|
143
|
+
unclaimTask: vi.fn().mockResolvedValue(undefined),
|
|
144
|
+
listClaimable: vi.fn().mockResolvedValue([]),
|
|
145
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
146
|
+
disconnect: vi.fn(),
|
|
147
|
+
connected: true,
|
|
148
|
+
} as unknown as TasksAdapter;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("promptUntilDone — S1 external completion predicate", () => {
|
|
152
|
+
let agentStore: AgentStore;
|
|
153
|
+
let inboxAdapter: InboxAdapter;
|
|
154
|
+
let manager: AgentManager;
|
|
155
|
+
|
|
156
|
+
beforeEach(() => {
|
|
157
|
+
promptCallCount = 0;
|
|
158
|
+
onHandleClose = null;
|
|
159
|
+
agentStore = new AgentStore(":memory:");
|
|
160
|
+
inboxAdapter = createMockInboxAdapter();
|
|
161
|
+
manager = createAgentManagerV2(agentStore, inboxAdapter, createMockTasksAdapter(), {
|
|
162
|
+
defaultCwd: "/tmp/test",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(async () => {
|
|
167
|
+
await manager.close();
|
|
168
|
+
agentStore.close();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("breaks on attempt 0 when isComplete flips true after the first update; terminates 'completed'; completedExternally:true; does NOT exhaust maxFollowUps", async () => {
|
|
172
|
+
promptStreamFactory = () => asAsync(oneMessageUpdate());
|
|
173
|
+
|
|
174
|
+
const spawned = await manager.spawn({ task: "tau episode", role: "worker" });
|
|
175
|
+
expect(manager.hasActiveSession(spawned.id)).toBe(true);
|
|
176
|
+
|
|
177
|
+
// Flip true after the first update is observed.
|
|
178
|
+
let updates = 0;
|
|
179
|
+
const isComplete = vi.fn(() => {
|
|
180
|
+
updates++;
|
|
181
|
+
return updates >= 1; // true from the first check on
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const maxFollowUps = 16;
|
|
185
|
+
const result = await manager.promptUntilDone(spawned.id, "go", {
|
|
186
|
+
maxFollowUps,
|
|
187
|
+
isComplete,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
expect(result.completedExternally).toBe(true);
|
|
191
|
+
expect(result.doneCalled).toBe(false);
|
|
192
|
+
// Only the initial attempt ran — no "please call done()" re-prompts.
|
|
193
|
+
expect(promptCallCount).toBe(1);
|
|
194
|
+
expect(isComplete).toHaveBeenCalled();
|
|
195
|
+
// Mirrors the done() path: the agent is terminated (its active session is
|
|
196
|
+
// torn down + the store record transitions to "stopped"). terminate() is an
|
|
197
|
+
// internal closure call, so we observe its EFFECT rather than spy on it.
|
|
198
|
+
expect(manager.hasActiveSession(spawned.id)).toBe(false);
|
|
199
|
+
expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("REPRO (live hang): silent-but-open stream + poll-only isComplete → concurrent poller terminates and returns bounded (completedExternally:true, promptCallCount===1)", async () => {
|
|
203
|
+
// The stream yields 2 updates then blocks forever without closing. The
|
|
204
|
+
// predicate is true from the start but can ONLY be observed via the
|
|
205
|
+
// concurrent poller, because no further updates arrive to drive the
|
|
206
|
+
// reactive per-update check, and the per-attempt check never runs (the
|
|
207
|
+
// for-await never ends on its own). On the OLD reactive-only code this
|
|
208
|
+
// test HANGS until the vitest timeout (red). With the concurrent poller it
|
|
209
|
+
// returns within a few poll intervals (green).
|
|
210
|
+
promptStreamFactory = silentThenHangStream();
|
|
211
|
+
|
|
212
|
+
const spawned = await manager.spawn({ task: "tau episode (silent)", role: "worker" });
|
|
213
|
+
expect(manager.hasActiveSession(spawned.id)).toBe(true);
|
|
214
|
+
|
|
215
|
+
// FALSE during the streamed updates (so the reactive per-update check
|
|
216
|
+
// (a) does NOT catch it), then TRUE once the episode has externally
|
|
217
|
+
// completed. After the 2 updates the stream goes silent forever, so the
|
|
218
|
+
// reactive checks never run again — ONLY the concurrent poller can observe
|
|
219
|
+
// the flip. (The 2 per-update reactive checks consume the first 2 false
|
|
220
|
+
// returns; everything after — i.e. the poller — sees true.)
|
|
221
|
+
let checks = 0;
|
|
222
|
+
const isComplete = vi.fn(() => {
|
|
223
|
+
checks++;
|
|
224
|
+
return checks > 2;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const pollIntervalMs = 20;
|
|
228
|
+
const maxFollowUps = 16;
|
|
229
|
+
|
|
230
|
+
const start = Date.now();
|
|
231
|
+
const result = await manager.promptUntilDone(spawned.id, "go", {
|
|
232
|
+
maxFollowUps,
|
|
233
|
+
isComplete,
|
|
234
|
+
pollIntervalMs,
|
|
235
|
+
});
|
|
236
|
+
const elapsed = Date.now() - start;
|
|
237
|
+
|
|
238
|
+
// Bounded return — NOT a hang, NOT maxFollowUps exhaustion.
|
|
239
|
+
expect(elapsed).toBeLessThan(2000);
|
|
240
|
+
expect(result.completedExternally).toBe(true);
|
|
241
|
+
expect(result.doneCalled).toBe(false);
|
|
242
|
+
// The whole episode is one prompt() call; no re-prompts.
|
|
243
|
+
expect(promptCallCount).toBe(1);
|
|
244
|
+
expect(isComplete).toHaveBeenCalled();
|
|
245
|
+
// terminate('completed') was effected: session torn down + store stopped.
|
|
246
|
+
expect(manager.hasActiveSession(spawned.id)).toBe(false);
|
|
247
|
+
expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("back-compat: with NO predicate, done()-detection is unchanged (doneCalled true, completedExternally false, terminate 'completed')", async () => {
|
|
251
|
+
promptStreamFactory = () => asAsync(doneToolUpdate());
|
|
252
|
+
|
|
253
|
+
const spawned = await manager.spawn({ task: "normal agent", role: "worker" });
|
|
254
|
+
|
|
255
|
+
const result = await manager.promptUntilDone(spawned.id, "go", {
|
|
256
|
+
maxFollowUps: 2,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(result.doneCalled).toBe(true);
|
|
260
|
+
expect(result.doneStatus).toBe("completed");
|
|
261
|
+
expect(result.completedExternally).toBe(false);
|
|
262
|
+
expect(promptCallCount).toBe(1);
|
|
263
|
+
// done()-detection still terminates the agent (unchanged behavior).
|
|
264
|
+
expect(manager.hasActiveSession(spawned.id)).toBe(false);
|
|
265
|
+
expect(agentStore.getAgent(spawned.id)!.state).toBe("stopped");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("no predicate + no done(): exhausts maxFollowUps (unchanged re-prompt behavior)", async () => {
|
|
269
|
+
// Empty stream each attempt — agent never calls done(), no predicate.
|
|
270
|
+
promptStreamFactory = () => asAsync([]);
|
|
271
|
+
|
|
272
|
+
const spawned = await manager.spawn({ task: "silent agent", role: "worker" });
|
|
273
|
+
|
|
274
|
+
const maxFollowUps = 3;
|
|
275
|
+
const result = await manager.promptUntilDone(spawned.id, "go", {
|
|
276
|
+
maxFollowUps,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result.doneCalled).toBe(false);
|
|
280
|
+
expect(result.completedExternally).toBe(false);
|
|
281
|
+
// Initial attempt + maxFollowUps re-prompts = maxFollowUps + 1 prompt calls.
|
|
282
|
+
expect(promptCallCount).toBe(maxFollowUps + 1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -48,7 +48,7 @@ import type {
|
|
|
48
48
|
AgentLifecycleEvent,
|
|
49
49
|
AgentConfig,
|
|
50
50
|
ContinueAgentOptions,
|
|
51
|
-
|
|
51
|
+
MCPServerStdioConfig,
|
|
52
52
|
} from "./types.js";
|
|
53
53
|
import { AgentManagerError } from "./types.js";
|
|
54
54
|
import type { RoleRegistry, Capability } from "../roles/types.js";
|
|
@@ -220,6 +220,12 @@ export function createAgentManagerV2(
|
|
|
220
220
|
const activeSessions = new Map<AgentId, ActiveSession>();
|
|
221
221
|
const agentWorkspaces = new Map<AgentId, Workspace>();
|
|
222
222
|
const lifecycleListeners = new Set<AgentLifecycleCallback>();
|
|
223
|
+
// In-flight fire-and-forget terminate() promises (e.g. the promptUntilDone
|
|
224
|
+
// completion poller tears the agent down without awaiting it, to keep
|
|
225
|
+
// completion latency low). close() awaits these so shutdown cannot return
|
|
226
|
+
// while a group-kill is still racing — which would let process.exit orphan
|
|
227
|
+
// the subtree.
|
|
228
|
+
const pendingTerminations = new Set<Promise<unknown>>();
|
|
223
229
|
let spawnInterceptor: SpawnInterceptor | null = null;
|
|
224
230
|
let isShuttingDown = false;
|
|
225
231
|
let mapServerUrl: string | undefined;
|
|
@@ -285,7 +291,7 @@ export function createAgentManagerV2(
|
|
|
285
291
|
lineage?: string[];
|
|
286
292
|
sessionId?: string;
|
|
287
293
|
streamId?: string;
|
|
288
|
-
}):
|
|
294
|
+
}): MCPServerStdioConfig {
|
|
289
295
|
const env: Record<string, string> = {
|
|
290
296
|
MACRO_AGENT_ID: opts.agentId,
|
|
291
297
|
MACRO_PARENT_ID: opts.parentId,
|
|
@@ -759,15 +765,28 @@ export function createAgentManagerV2(
|
|
|
759
765
|
value: v,
|
|
760
766
|
})),
|
|
761
767
|
},
|
|
762
|
-
...(agentConfig?.mcpServers?.map((s) =>
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
768
|
+
...(agentConfig?.mcpServers?.map((s) => {
|
|
769
|
+
if ("command" in s) {
|
|
770
|
+
return {
|
|
771
|
+
name: s.name,
|
|
772
|
+
command: s.command,
|
|
773
|
+
args: s.args ?? [],
|
|
774
|
+
env: Object.entries(s.env ?? {}).map(([k, v]) => ({
|
|
775
|
+
name: k,
|
|
776
|
+
value: v,
|
|
777
|
+
})),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
return {
|
|
781
|
+
name: s.name,
|
|
782
|
+
type: s.type,
|
|
783
|
+
url: s.url,
|
|
784
|
+
headers: Object.entries(s.headers ?? {}).map(([k, v]) => ({
|
|
785
|
+
name: k,
|
|
786
|
+
value: v,
|
|
787
|
+
})),
|
|
788
|
+
};
|
|
789
|
+
}) ?? []),
|
|
771
790
|
];
|
|
772
791
|
|
|
773
792
|
// Always-on subsystem MCP servers (the "trinity"). The macro-agent
|
|
@@ -1753,63 +1772,189 @@ export function createAgentManagerV2(
|
|
|
1753
1772
|
options?: {
|
|
1754
1773
|
maxFollowUps?: number;
|
|
1755
1774
|
onUpdate?: (update: ExtendedSessionUpdate) => void;
|
|
1775
|
+
/**
|
|
1776
|
+
* Optional caller-supplied completion predicate. When it returns true the
|
|
1777
|
+
* loop stops consuming, terminates the agent as "completed", and returns
|
|
1778
|
+
* `completedExternally: true` — WITHOUT requiring the macro `done()` tool.
|
|
1779
|
+
*
|
|
1780
|
+
* Checked three ways (all fire terminate + return completedExternally):
|
|
1781
|
+
* (a) reactively after each streamed update (fast path),
|
|
1782
|
+
* (b) reactively after each attempt's stream ends (fast path),
|
|
1783
|
+
* (c) on a CONCURRENT ~2s timer while consuming the stream. (c) is the
|
|
1784
|
+
* critical path for the live τ rollout: the agent runs the whole
|
|
1785
|
+
* episode in ONE prompt() call, calls τ's finish, the env writes its
|
|
1786
|
+
* reward sink (done:true), and then the prompt() stream goes SILENT
|
|
1787
|
+
* and does NOT close — the async iterator just blocks. With only the
|
|
1788
|
+
* reactive checks (a)/(b) the predicate is never re-evaluated and
|
|
1789
|
+
* solve() hangs until the rollout backstop. The concurrent poller
|
|
1790
|
+
* observes done:true independent of update arrival, flips
|
|
1791
|
+
* completedExternally, and terminate()s the agent — which ends the
|
|
1792
|
+
* in-flight prompt() generator (via return or throw, both handled).
|
|
1793
|
+
*
|
|
1794
|
+
* Generic by design: this module has NO knowledge of what the predicate
|
|
1795
|
+
* reads (e.g. an external reward sink). Back-compat: when absent, NO poller
|
|
1796
|
+
* is started and behavior is unchanged — only `done()` detection drives
|
|
1797
|
+
* completion.
|
|
1798
|
+
*
|
|
1799
|
+
* @param pollIntervalMs Override the concurrent poll cadence (default
|
|
1800
|
+
* 2000ms). Primarily a test seam.
|
|
1801
|
+
*/
|
|
1802
|
+
isComplete?: () => boolean | Promise<boolean>;
|
|
1803
|
+
pollIntervalMs?: number;
|
|
1756
1804
|
}
|
|
1757
1805
|
): Promise<{
|
|
1758
1806
|
doneCalled: boolean;
|
|
1759
1807
|
doneStatus?: string;
|
|
1808
|
+
completedExternally: boolean;
|
|
1760
1809
|
updates: ExtendedSessionUpdate[];
|
|
1761
1810
|
}> {
|
|
1762
1811
|
const maxFollowUps = options?.maxFollowUps ?? 2;
|
|
1812
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 2000;
|
|
1763
1813
|
const allUpdates: ExtendedSessionUpdate[] = [];
|
|
1764
1814
|
let doneCalled = false;
|
|
1765
1815
|
let doneStatus: string | undefined;
|
|
1816
|
+
let completedExternally = false;
|
|
1766
1817
|
|
|
1767
1818
|
let currentMessage = message;
|
|
1768
1819
|
|
|
1769
1820
|
for (let attempt = 0; attempt <= maxFollowUps; attempt++) {
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1821
|
+
// ── Concurrent completion poller ─────────────────────────────────────
|
|
1822
|
+
// Only armed when the caller supplies a predicate (back-compat: no
|
|
1823
|
+
// predicate → no poller, identical behavior). The live failure mode is a
|
|
1824
|
+
// prompt() stream that has gone SILENT but not CLOSED after the episode
|
|
1825
|
+
// externally completed; the reactive per-update check never fires again
|
|
1826
|
+
// because no updates arrive. This timer re-evaluates isComplete()
|
|
1827
|
+
// independent of update arrival. On true it flips completedExternally and
|
|
1828
|
+
// terminate()s the agent, which ends the in-flight prompt() generator
|
|
1829
|
+
// (return or throw) so the for-await below unblocks. The interval is
|
|
1830
|
+
// always cleared in `finally` so no timer leaks across attempts.
|
|
1831
|
+
let pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
1832
|
+
let polling = false;
|
|
1833
|
+
|
|
1834
|
+
// Resolves the instant completion is detected (external predicate OR the
|
|
1835
|
+
// macro done() tool). The attempt is RACED against this signal so it
|
|
1836
|
+
// returns WITHOUT waiting for the prompt() generator to drain or for
|
|
1837
|
+
// terminate() to finish. The prior design relied on terminate() ending
|
|
1838
|
+
// the silent in-flight stream to unblock the for-await; when that teardown
|
|
1839
|
+
// stalled (e.g. a long, busy real episode whose subprocess is slow to die),
|
|
1840
|
+
// promptUntilDone never returned and the macro session stayed "running"
|
|
1841
|
+
// until the multi-hour hard-timer — the refinement "finalization hang".
|
|
1842
|
+
let signalDone: () => void = () => {};
|
|
1843
|
+
const doneSignal = new Promise<void>((resolve) => {
|
|
1844
|
+
signalDone = resolve;
|
|
1845
|
+
});
|
|
1773
1846
|
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1847
|
+
const startPoller = () => {
|
|
1848
|
+
if (!options?.isComplete) return;
|
|
1849
|
+
pollTimer = setInterval(() => {
|
|
1850
|
+
if (polling || completedExternally) return;
|
|
1851
|
+
polling = true;
|
|
1852
|
+
void (async () => {
|
|
1853
|
+
try {
|
|
1854
|
+
if (!completedExternally && options.isComplete && (await options.isComplete())) {
|
|
1855
|
+
completedExternally = true;
|
|
1856
|
+
signalDone();
|
|
1857
|
+
// Tear down the agent/session in the BACKGROUND — never await it
|
|
1858
|
+
// here, so a slow teardown cannot gate the return. Tracked in
|
|
1859
|
+
// pendingTerminations so close() awaits it before shutdown
|
|
1860
|
+
// returns (else a still-racing group-kill is abandoned by a
|
|
1861
|
+
// subsequent process.exit, orphaning the subtree). Best effort.
|
|
1862
|
+
{
|
|
1863
|
+
const t = terminate(agentId, "completed" as any).catch(() => {});
|
|
1864
|
+
pendingTerminations.add(t);
|
|
1865
|
+
void t.finally(() => pendingTerminations.delete(t));
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
} catch {
|
|
1869
|
+
// Predicate errored — leave completion to the reactive checks.
|
|
1870
|
+
} finally {
|
|
1871
|
+
polling = false;
|
|
1872
|
+
}
|
|
1873
|
+
})();
|
|
1874
|
+
}, pollIntervalMs);
|
|
1875
|
+
};
|
|
1777
1876
|
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1877
|
+
// Consume the stream as a background task so a silent-but-open generator
|
|
1878
|
+
// can be raced against the completion signal below.
|
|
1879
|
+
const consume = (async () => {
|
|
1880
|
+
for await (const update of prompt(agentId, currentMessage)) {
|
|
1881
|
+
allUpdates.push(update);
|
|
1882
|
+
options?.onUpdate?.(update);
|
|
1883
|
+
|
|
1884
|
+
// External completion predicate (reactive fast path).
|
|
1885
|
+
if (options?.isComplete && (await options.isComplete())) {
|
|
1886
|
+
completedExternally = true;
|
|
1887
|
+
signalDone();
|
|
1888
|
+
break;
|
|
1889
|
+
}
|
|
1890
|
+
if (completedExternally) break;
|
|
1891
|
+
|
|
1892
|
+
// Detect done() tool call from session updates.
|
|
1893
|
+
// acp-factory uses { sessionUpdate: "tool_call", title: "mcp__macro-agent__done" }
|
|
1894
|
+
const uAny = update as any;
|
|
1895
|
+
|
|
1896
|
+
// Check title field (primary detection)
|
|
1897
|
+
if (
|
|
1898
|
+
(uAny.sessionUpdate === "tool_call" || uAny.sessionUpdate === "tool_call_update") &&
|
|
1899
|
+
typeof uAny.title === "string" &&
|
|
1900
|
+
uAny.title.endsWith("__done")
|
|
1901
|
+
) {
|
|
1902
|
+
doneCalled = true;
|
|
1903
|
+
// Extract status from rawInput (may arrive across multiple updates —
|
|
1904
|
+
// first update has rawInput={}, subsequent has full input)
|
|
1905
|
+
try {
|
|
1906
|
+
const raw = uAny.rawInput;
|
|
1907
|
+
const input =
|
|
1908
|
+
typeof raw === "string" ? JSON.parse(raw) :
|
|
1909
|
+
typeof raw === "object" ? raw :
|
|
1910
|
+
uAny.input;
|
|
1911
|
+
if (input?.status) {
|
|
1912
|
+
doneStatus = input.status;
|
|
1913
|
+
}
|
|
1914
|
+
} catch {
|
|
1915
|
+
// Best effort — rawInput may not be parseable yet
|
|
1795
1916
|
}
|
|
1796
|
-
|
|
1797
|
-
// Best effort — rawInput may not be parseable yet
|
|
1917
|
+
signalDone();
|
|
1798
1918
|
}
|
|
1799
|
-
}
|
|
1800
1919
|
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1920
|
+
// Fallback: check older format
|
|
1921
|
+
if (
|
|
1922
|
+
uAny.type === "result" &&
|
|
1923
|
+
uAny.subtype === "tool_result" &&
|
|
1924
|
+
uAny.toolName === "done"
|
|
1925
|
+
) {
|
|
1926
|
+
doneCalled = true;
|
|
1927
|
+
doneStatus = uAny.result?.status;
|
|
1928
|
+
signalDone();
|
|
1929
|
+
}
|
|
1809
1930
|
}
|
|
1931
|
+
})();
|
|
1932
|
+
// Prevent an unhandled rejection if we return via doneSignal before the
|
|
1933
|
+
// now-orphaned stream throws from a background teardown.
|
|
1934
|
+
consume.catch(() => {});
|
|
1935
|
+
|
|
1936
|
+
try {
|
|
1937
|
+
startPoller();
|
|
1938
|
+
// Return as soon as EITHER the stream drains naturally OR completion is
|
|
1939
|
+
// signalled — whichever comes first. This is the key change: completion
|
|
1940
|
+
// no longer depends on the generator unblocking.
|
|
1941
|
+
await Promise.race([consume, doneSignal]);
|
|
1942
|
+
} catch (err) {
|
|
1943
|
+
// A genuine stream error (not a post-completion teardown) re-throws.
|
|
1944
|
+
if (!completedExternally && !doneCalled) throw err;
|
|
1945
|
+
} finally {
|
|
1946
|
+
// Always clear the concurrent poller so no timer leaks across attempts.
|
|
1947
|
+
if (pollTimer) clearInterval(pollTimer);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// Re-check after the stream ends in case completion landed exactly as the
|
|
1951
|
+
// attempt's stream drained (the inner break / poller already set the flag
|
|
1952
|
+
// when it fired mid-stream).
|
|
1953
|
+
if (!completedExternally && options?.isComplete && (await options.isComplete())) {
|
|
1954
|
+
completedExternally = true;
|
|
1810
1955
|
}
|
|
1811
1956
|
|
|
1812
|
-
if (doneCalled) break;
|
|
1957
|
+
if (doneCalled || completedExternally) break;
|
|
1813
1958
|
|
|
1814
1959
|
if (attempt < maxFollowUps) {
|
|
1815
1960
|
currentMessage =
|
|
@@ -1817,9 +1962,11 @@ export function createAgentManagerV2(
|
|
|
1817
1962
|
}
|
|
1818
1963
|
}
|
|
1819
1964
|
|
|
1820
|
-
// Auto-terminate when
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1965
|
+
// Auto-terminate when the agent completed — either via the macro done() tool
|
|
1966
|
+
// or via the caller's external completion predicate. This closes the
|
|
1967
|
+
// lifecycle gap: without this, agents stay in "running" state because
|
|
1968
|
+
// nothing triggers terminate(). The external path mirrors the done() path
|
|
1969
|
+
// (terminate as "completed").
|
|
1823
1970
|
if (doneCalled) {
|
|
1824
1971
|
const reason = doneStatus === "completed" ? "completed" : (doneStatus ?? "failed");
|
|
1825
1972
|
try {
|
|
@@ -1827,9 +1974,15 @@ export function createAgentManagerV2(
|
|
|
1827
1974
|
} catch {
|
|
1828
1975
|
// Best effort — agent may already be stopping
|
|
1829
1976
|
}
|
|
1977
|
+
} else if (completedExternally) {
|
|
1978
|
+
try {
|
|
1979
|
+
await terminate(agentId, "completed" as any);
|
|
1980
|
+
} catch {
|
|
1981
|
+
// Best effort — agent may already be stopping
|
|
1982
|
+
}
|
|
1830
1983
|
}
|
|
1831
1984
|
|
|
1832
|
-
return { doneCalled, doneStatus, updates: allUpdates };
|
|
1985
|
+
return { doneCalled, doneStatus, completedExternally, updates: allUpdates };
|
|
1833
1986
|
}
|
|
1834
1987
|
|
|
1835
1988
|
function getSession(agentId: AgentId): Session | null {
|
|
@@ -1974,6 +2127,19 @@ export function createAgentManagerV2(
|
|
|
1974
2127
|
});
|
|
1975
2128
|
}
|
|
1976
2129
|
activeSessions.clear();
|
|
2130
|
+
|
|
2131
|
+
// Await any in-flight fire-and-forget terminations (e.g. the completion
|
|
2132
|
+
// poller's background teardown) so shutdown does not return while a
|
|
2133
|
+
// group-kill is still racing — otherwise a follow-on process.exit abandons
|
|
2134
|
+
// it and orphans the acp/claude/mcp_env subtree. Bounded by a backstop so a
|
|
2135
|
+
// genuinely stuck close() cannot wedge shutdown.
|
|
2136
|
+
if (pendingTerminations.size > 0) {
|
|
2137
|
+
await Promise.race([
|
|
2138
|
+
Promise.allSettled([...pendingTerminations]),
|
|
2139
|
+
new Promise((resolve) => setTimeout(resolve, 6000)),
|
|
2140
|
+
]);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
1977
2143
|
agentWorkspaces.clear();
|
|
1978
2144
|
lifecycleListeners.clear();
|
|
1979
2145
|
|
|
@@ -164,10 +164,27 @@ export interface AgentManager {
|
|
|
164
164
|
maxFollowUps?: number;
|
|
165
165
|
/** Callback for each update during prompting */
|
|
166
166
|
onUpdate?: (update: ExtendedSessionUpdate) => void;
|
|
167
|
+
/**
|
|
168
|
+
* Optional caller-supplied completion predicate. When it returns true the
|
|
169
|
+
* loop stops early and terminates the agent as "completed" WITHOUT
|
|
170
|
+
* requiring the macro done() tool. Back-compat: absent → unchanged.
|
|
171
|
+
*
|
|
172
|
+
* Evaluated reactively per-update/per-attempt AND on a concurrent timer
|
|
173
|
+
* (see implementation) so a silent-but-open prompt() stream still
|
|
174
|
+
* completes. No predicate → no poller, unchanged behavior.
|
|
175
|
+
*/
|
|
176
|
+
isComplete?: () => boolean | Promise<boolean>;
|
|
177
|
+
/**
|
|
178
|
+
* Override the concurrent isComplete() poll cadence in ms (default 2000).
|
|
179
|
+
* Only meaningful alongside `isComplete`; primarily a test seam.
|
|
180
|
+
*/
|
|
181
|
+
pollIntervalMs?: number;
|
|
167
182
|
},
|
|
168
183
|
): Promise<{
|
|
169
184
|
doneCalled: boolean;
|
|
170
185
|
doneStatus?: string;
|
|
186
|
+
/** True when the caller's isComplete() predicate ended the loop. */
|
|
187
|
+
completedExternally: boolean;
|
|
171
188
|
updates: ExtendedSessionUpdate[];
|
|
172
189
|
}>;
|
|
173
190
|
|