macro-agent 0.1.6 → 0.1.8

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.
@@ -12,11 +12,9 @@ import type { TaskBridge } from "../types.js";
12
12
  import type { Agent } from "../../store/types/index.js";
13
13
 
14
14
  function mockConnection(): LifecycleBridgeConnection & {
15
- spawn: ReturnType<typeof vi.fn>;
16
15
  callExtension: ReturnType<typeof vi.fn>;
17
16
  } {
18
17
  return {
19
- spawn: vi.fn().mockResolvedValue({}),
20
18
  callExtension: vi.fn().mockResolvedValue({}),
21
19
  get isConnected() {
22
20
  return true;
@@ -70,16 +68,61 @@ describe("LifecycleBridge", () => {
70
68
  agent: mockAgent({ id: "agent-1", name: "worker-1", role: "worker" }),
71
69
  });
72
70
 
73
- expect(conn.spawn).toHaveBeenCalledWith(
71
+ expect(conn.callExtension).toHaveBeenCalledWith(
72
+ "map/agents/register",
74
73
  expect.objectContaining({
75
- agentId: "agent-1",
76
74
  name: "worker-1",
77
75
  role: "worker",
78
- scopes: [scope],
79
76
  }),
80
77
  );
81
78
  });
82
79
 
80
+ it("includes per-agent ACP capabilities for coordinators", () => {
81
+ const { callback } = createLifecycleBridge(
82
+ conn,
83
+ {} as AgentStore,
84
+ scope,
85
+ );
86
+
87
+ callback({
88
+ type: "spawned",
89
+ agent: mockAgent({ id: "coord-1", name: "coordinator-1", role: "coordinator" }),
90
+ });
91
+
92
+ expect(conn.callExtension).toHaveBeenCalledWith(
93
+ "map/agents/register",
94
+ expect.objectContaining({
95
+ name: "coordinator-1",
96
+ role: "coordinator",
97
+ capabilities: expect.objectContaining({
98
+ protocols: ["acp"],
99
+ acp: { version: "2024-10-07" },
100
+ messaging: { canReceive: true },
101
+ }),
102
+ }),
103
+ );
104
+ });
105
+
106
+ it("does not include ACP capabilities for workers", () => {
107
+ const { callback } = createLifecycleBridge(
108
+ conn,
109
+ {} as AgentStore,
110
+ scope,
111
+ );
112
+
113
+ callback({
114
+ type: "spawned",
115
+ agent: mockAgent({ id: "worker-1", name: "worker-1", role: "worker" }),
116
+ });
117
+
118
+ const call = conn.callExtension.mock.calls[0];
119
+ const params = call[1] as Record<string, unknown>;
120
+ const caps = params.capabilities as Record<string, unknown>;
121
+ expect(caps.protocols).toBeUndefined();
122
+ expect(caps.acp).toBeUndefined();
123
+ expect(caps.messaging).toEqual({ canReceive: true });
124
+ });
125
+
83
126
  it("unregisters agent from MAP hub on stop event", () => {
84
127
  const { callback } = createLifecycleBridge(
85
128
  conn,
@@ -98,12 +141,39 @@ describe("LifecycleBridge", () => {
98
141
  expect(conn.callExtension).toHaveBeenCalledWith(
99
142
  "map/agents/unregister",
100
143
  expect.objectContaining({
101
- agentId: "agent-1",
102
144
  reason: "completed",
103
145
  }),
104
146
  );
105
147
  });
106
148
 
149
+ it("uses MAP-assigned ID for unregistration when available", async () => {
150
+ conn.callExtension.mockResolvedValueOnce({ agent: { id: "map-ulid-1" } });
151
+
152
+ const { callback } = createLifecycleBridge(
153
+ conn,
154
+ {} as AgentStore,
155
+ scope,
156
+ );
157
+
158
+ callback({ type: "spawned", agent: mockAgent({ id: "agent-1" }) });
159
+
160
+ // Wait for the .then() that stores mapId
161
+ await new Promise(r => setTimeout(r, 10));
162
+
163
+ callback({
164
+ type: "stopped",
165
+ agent: mockAgent({ id: "agent-1" }),
166
+ reason: "completed",
167
+ });
168
+
169
+ expect(conn.callExtension).toHaveBeenLastCalledWith(
170
+ "map/agents/unregister",
171
+ expect.objectContaining({
172
+ agentId: "map-ulid-1",
173
+ }),
174
+ );
175
+ });
176
+
107
177
  it("does nothing when disconnected", () => {
108
178
  const disconnected = {
109
179
  ...conn,
@@ -119,7 +189,7 @@ describe("LifecycleBridge", () => {
119
189
 
120
190
  callback({ type: "spawned", agent: mockAgent() });
121
191
 
122
- expect(conn.spawn).not.toHaveBeenCalled();
192
+ expect(conn.callExtension).not.toHaveBeenCalled();
123
193
  });
124
194
 
125
195
  it("bridges task creation on spawn when agent has task_id", () => {
@@ -178,19 +248,15 @@ describe("LifecycleBridge", () => {
178
248
 
179
249
  await cleanup();
180
250
 
181
- expect(conn.callExtension).toHaveBeenCalledTimes(2);
182
- expect(conn.callExtension).toHaveBeenCalledWith(
183
- "map/agents/unregister",
184
- expect.objectContaining({ agentId: "a1" }),
185
- );
186
- expect(conn.callExtension).toHaveBeenCalledWith(
187
- "map/agents/unregister",
188
- expect.objectContaining({ agentId: "a2" }),
251
+ // 2 register calls + 2 unregister calls
252
+ const unregisterCalls = conn.callExtension.mock.calls.filter(
253
+ (c: any[]) => c[0] === "map/agents/unregister",
189
254
  );
255
+ expect(unregisterCalls).toHaveLength(2);
190
256
  });
191
257
 
192
258
  it("silently handles MAP call failures", () => {
193
- conn.spawn.mockRejectedValue(new Error("network error"));
259
+ conn.callExtension.mockRejectedValue(new Error("network error"));
194
260
 
195
261
  const { callback } = createLifecycleBridge(
196
262
  conn,
@@ -216,7 +282,8 @@ describe("LifecycleBridge", () => {
216
282
  agent: mockAgent({ id: "agent-99", name: undefined }),
217
283
  });
218
284
 
219
- expect(conn.spawn).toHaveBeenCalledWith(
285
+ expect(conn.callExtension).toHaveBeenCalledWith(
286
+ "map/agents/register",
220
287
  expect.objectContaining({ name: "agent-99" }),
221
288
  );
222
289
  });
@@ -13,13 +13,6 @@ import type { TaskBridge } from "./types.js";
13
13
 
14
14
  /** Minimal interface for the MAP connection methods we need */
15
15
  export interface LifecycleBridgeConnection {
16
- spawn(options: {
17
- agentId?: string | undefined;
18
- name?: string | undefined;
19
- role?: string | undefined;
20
- scopes?: string[];
21
- metadata?: Record<string, unknown>;
22
- }): Promise<unknown>;
23
16
  callExtension(method: string, params?: unknown): Promise<unknown>;
24
17
  get isConnected(): boolean;
25
18
  }
@@ -28,6 +21,8 @@ interface RegisteredAgent {
28
21
  id: string;
29
22
  name: string;
30
23
  role: string;
24
+ /** MAP-assigned agent ID (ULID) from the hub, used for unregistration */
25
+ mapId?: string;
31
26
  }
32
27
 
33
28
  /**
@@ -55,19 +50,37 @@ export function createLifecycleBridge(
55
50
  const entry: RegisteredAgent = { id: agent.id, name, role };
56
51
  registered.set(agent.id, entry);
57
52
 
58
- // Register agent with MAP hub
53
+ // Build per-agent capabilities.
54
+ // Coordinators (head managers) support ACP for interactive chat.
55
+ const capabilities: Record<string, unknown> = {
56
+ messaging: { canReceive: true },
57
+ };
58
+ if (role === "coordinator") {
59
+ capabilities.protocols = ["acp"];
60
+ capabilities.acp = { version: "2024-10-07" };
61
+ }
62
+
63
+ // Register agent with MAP hub (use map/agents/register to preserve
64
+ // per-agent capabilities; map/agents/spawn drops them)
59
65
  connection
60
- .spawn({
61
- agentId: agent.id,
66
+ .callExtension("map/agents/register", {
62
67
  name,
63
68
  role,
64
- scopes: [scope],
69
+ capabilities,
65
70
  metadata: {
71
+ localAgentId: agent.id,
66
72
  parent: (agent as any).parent_id ?? undefined,
67
73
  team: (agent as any).team ?? undefined,
68
74
  cwd: (agent as any).cwd ?? undefined,
69
75
  },
70
76
  })
77
+ .then((result: any) => {
78
+ // Track the MAP-assigned agent ID for unregistration
79
+ const mapId = result?.agent?.id ?? result?.id;
80
+ if (mapId) {
81
+ entry.mapId = mapId;
82
+ }
83
+ })
71
84
  .catch(() => {
72
85
  // Silent — MAP hub may be temporarily unavailable
73
86
  });
@@ -88,12 +101,14 @@ export function createLifecycleBridge(
88
101
 
89
102
  case "stopped": {
90
103
  const agent = event.agent;
104
+ const entry = registered.get(agent.id);
91
105
  registered.delete(agent.id);
92
106
 
93
- // Unregister agent from MAP hub
107
+ // Unregister agent from MAP hub (use MAP-assigned ID if available)
108
+ const unregId = entry?.mapId ?? agent.id;
94
109
  connection
95
110
  .callExtension("map/agents/unregister", {
96
- agentId: agent.id,
111
+ agentId: unregId,
97
112
  reason: event.reason ?? "stopped",
98
113
  })
99
114
  .catch(() => {
@@ -123,11 +138,11 @@ export function createLifecycleBridge(
123
138
  registered.clear();
124
139
  return;
125
140
  }
126
- // Unregister all tracked agents
127
- const promises = Array.from(registered.keys()).map((agentId) =>
141
+ // Unregister all tracked agents (use MAP-assigned IDs)
142
+ const promises = Array.from(registered.values()).map((entry) =>
128
143
  connection
129
144
  .callExtension("map/agents/unregister", {
130
- agentId,
145
+ agentId: entry.mapId ?? entry.id,
131
146
  reason: "sidecar_shutdown",
132
147
  })
133
148
  .catch(() => {}),
package/src/map/server.ts CHANGED
@@ -290,14 +290,26 @@ export function createMAPServerInstance(
290
290
  const message = data?.message;
291
291
  if (!message) return;
292
292
 
293
+ // Check if this is an ACP envelope — these should always be handled
294
+ // by the bridge, even if the target agent can't be resolved to a
295
+ // specific local agent (the bridge creates a head manager on demand).
296
+ const payload = message?.payload;
297
+ const isAcp = payload && typeof payload === 'object' &&
298
+ 'acp' in payload && 'acpContext' in payload;
299
+
293
300
  const toField = message.to;
294
301
  const mapTargetId = data?.agentId ??
295
302
  (typeof toField === "string" ? toField : toField?.agent ?? toField?.id);
296
303
  if (!mapTargetId) return;
297
304
 
298
305
  const localAgentId = mapIdToLocalId.get(mapTargetId) ?? mapTargetId;
299
- const localAgent = deps.agentManager.get(localAgentId);
300
- if (!localAgent) return;
306
+
307
+ // For ACP envelopes, always forward to bridge (it creates sessions on demand).
308
+ // For non-ACP messages, require a local agent to exist.
309
+ if (!isAcp) {
310
+ const localAgent = deps.agentManager.get(localAgentId);
311
+ if (!localAgent) return;
312
+ }
301
313
 
302
314
  // Defer ACP processing to next tick so map/send response goes out first
303
315
  setImmediate(() => {
@@ -64,6 +64,12 @@ export function createMAPSidecar(
64
64
  if (config.token) {
65
65
  parsed.searchParams.set("token", config.token);
66
66
  }
67
+ // Include swarm_id for stable identity across reconnections.
68
+ // When set, the hub reuses the pre-registered swarm record instead
69
+ // of auto-generating a new one on each connection.
70
+ if (config.swarmId) {
71
+ parsed.searchParams.set("swarm_id", config.swarmId);
72
+ }
67
73
  return parsed.toString();
68
74
  }
69
75
 
@@ -109,8 +115,6 @@ export function createMAPSidecar(
109
115
  capabilities: {
110
116
  messaging: { canSend: true, canReceive: true },
111
117
  mail: { canCreate: true, canJoin: true, canViewHistory: true },
112
- protocols: ['acp'],
113
- acp: { version: '2024-10-07' },
114
118
  trajectory: { canReport: true, canServeContent: false },
115
119
  tasks: {
116
120
  canCreate: true,
package/src/map/types.ts CHANGED
@@ -35,6 +35,9 @@ export interface MAPSidecarConfig {
35
35
  /** Agent name for MAP registration (default: "macro-agent-sidecar") */
36
36
  agentName?: string;
37
37
 
38
+ /** Swarm ID for stable identity across reconnections */
39
+ swarmId?: string;
40
+
38
41
  /** Trajectory sync level */
39
42
  trajectorySyncLevel?: "off" | "lifecycle" | "metrics" | "full";
40
43