amalgm 0.1.154 → 0.1.155

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.
@@ -4,12 +4,9 @@ const crypto = require('crypto');
4
4
 
5
5
  const { normalizeAgentConfig } = require('../agent-config/schema');
6
6
 
7
- // Agent-only bundles keep the legacy kind so older runtimes can still install
8
- // them; bundles that carry automations or apps use the general kind so older
9
- // runtimes reject them outright instead of silently dropping primitives.
10
7
  const BUNDLE_KIND = 'amalgm.bundle';
11
- const LEGACY_AGENT_BUNDLE_KIND = 'amalgm.agent.bundle';
12
- const BUNDLE_SCHEMA_VERSION = 1;
8
+ const BUNDLE_SCHEMA_VERSION = 2;
9
+ const HEAD_TYPES = new Set(['agent', 'automation', 'app', 'tool']);
13
10
  const GRAPH_ELEMENT_ORDER = {
14
11
  agent: 0,
15
12
  automation: 1,
@@ -41,8 +38,7 @@ function uniqueStrings(values) {
41
38
  }
42
39
 
43
40
  function addUnique(target, value) {
44
- if (!value) return;
45
- if (!target.includes(value)) target.push(value);
41
+ if (value && !target.includes(value)) target.push(value);
46
42
  }
47
43
 
48
44
  function stableDigest(value) {
@@ -62,12 +58,12 @@ function skillFingerprint(skill) {
62
58
  return value ? `string:${value}` : '';
63
59
  }
64
60
  if (!isObject(skill)) return '';
65
- const id = typeof skill.id === 'string' ? skill.id.trim() : '';
61
+ const id = cleanEntryId(skill.id);
66
62
  if (id) return `id:${id}`;
67
- const path = typeof skill.path === 'string' ? skill.path.trim() : '';
63
+ const path = cleanEntryId(skill.path);
68
64
  if (path) return `path:${path}`;
69
- const name = typeof skill.name === 'string' ? skill.name.trim() : '';
70
- const content = typeof skill.content === 'string' ? skill.content.trim() : '';
65
+ const name = cleanEntryId(skill.name);
66
+ const content = cleanEntryId(skill.content);
71
67
  if (name && content) return `name-content:${name}:${content}`;
72
68
  if (name) return `name:${name}`;
73
69
  if (content) return `content:${content}`;
@@ -82,20 +78,18 @@ function skillElementId(skill) {
82
78
  function skillLabel(skill) {
83
79
  if (typeof skill === 'string') return skill.trim() || 'Skill';
84
80
  if (!isObject(skill)) return 'Skill';
85
- const id = typeof skill.id === 'string' ? skill.id.trim() : '';
86
- const name = typeof skill.name === 'string' ? skill.name.trim() : '';
87
- const path = typeof skill.path === 'string' ? skill.path.trim() : '';
81
+ const name = cleanEntryId(skill.name);
88
82
  if (name) return name;
83
+ const path = cleanEntryId(skill.path);
89
84
  if (path) return path.split('/').filter(Boolean).pop() || path;
85
+ const id = cleanEntryId(skill.id);
90
86
  if (id) return id;
91
- const content = typeof skill.content === 'string' ? skill.content.trim() : '';
92
- return content.slice(0, 60) || 'Skill';
87
+ return cleanEntryId(skill.content).slice(0, 60) || 'Skill';
93
88
  }
94
89
 
95
90
  function compareGraphElements(left, right) {
96
- const order = (GRAPH_ELEMENT_ORDER[left.type] || 99) - (GRAPH_ELEMENT_ORDER[right.type] || 99);
97
- if (order !== 0) return order;
98
- return String(left.id).localeCompare(String(right.id));
91
+ return (GRAPH_ELEMENT_ORDER[left.type] ?? 99) - (GRAPH_ELEMENT_ORDER[right.type] ?? 99)
92
+ || String(left.id).localeCompare(String(right.id));
99
93
  }
100
94
 
101
95
  function compareGraphEdges(left, right) {
@@ -104,38 +98,13 @@ function compareGraphEdges(left, right) {
104
98
  || String(left.to).localeCompare(String(right.to));
105
99
  }
106
100
 
107
- function pushGraphElement(elements, element) {
108
- if (!element?.id) return;
109
- if (!elements.has(element.id)) elements.set(element.id, element);
110
- }
111
-
112
- function pushGraphEdge(edges, edge) {
113
- if (!edge?.from || !edge?.to || !edge?.kind) return;
114
- const key = `${edge.from}::${edge.kind}::${edge.to}`;
115
- if (!edges.has(key)) edges.set(key, edge);
116
- }
117
-
118
- function collectSkillRecords(bundleAgents) {
119
- const byKey = new Map();
120
- for (const entry of bundleAgents) {
121
- for (const skill of entry.config.skills || []) {
122
- const key = skillFingerprint(skill);
123
- if (!key) continue;
124
- byKey.set(key, cloneJson(skill));
125
- }
126
- }
127
- return [...byKey.values()];
128
- }
129
-
130
101
  function cleanEntryId(value) {
131
102
  return typeof value === 'string' ? value.trim() : '';
132
103
  }
133
104
 
134
105
  function agentEntryId(entry) {
135
106
  if (!isObject(entry)) return '';
136
- const sourceAgentId = typeof entry.sourceAgentId === 'string' ? entry.sourceAgentId.trim() : '';
137
- if (sourceAgentId) return sourceAgentId;
138
- return typeof entry.agent?.sourceAgentId === 'string' ? entry.agent.sourceAgentId.trim() : '';
107
+ return cleanEntryId(entry.sourceAgentId) || cleanEntryId(entry.agent?.sourceAgentId);
139
108
  }
140
109
 
141
110
  function automationEntryId(entry) {
@@ -146,294 +115,294 @@ function appEntryId(entry) {
146
115
  return isObject(entry) ? cleanEntryId(entry.sourceAppId) : '';
147
116
  }
148
117
 
149
- /**
150
- * Tools shared as top-level primitives (not just dependencies of agents or
151
- * automations). The bundle's explicit heads are the single source of truth.
152
- */
118
+ function toolEntryId(entry) {
119
+ return isObject(entry) ? cleanEntryId(entry.id) : '';
120
+ }
121
+
122
+ function actionEntryId(entry) {
123
+ return isObject(entry) ? cleanEntryId(entry.id) : '';
124
+ }
125
+
126
+ function collectSkillRecords(bundleAgents) {
127
+ const byKey = new Map();
128
+ for (const entry of bundleAgents) {
129
+ const config = normalizeAgentConfig(entry?.config || {});
130
+ for (const skill of config.skills || []) {
131
+ const key = skillFingerprint(skill);
132
+ if (key && !byKey.has(key)) byKey.set(key, cloneJson(skill));
133
+ }
134
+ }
135
+ return [...byKey.values()];
136
+ }
137
+
153
138
  function standaloneToolIds(bundle) {
154
- const heads = Array.isArray(bundle?.heads) ? bundle.heads : [];
155
- return uniqueStrings(heads
139
+ return uniqueStrings((Array.isArray(bundle?.heads) ? bundle.heads : [])
156
140
  .filter((head) => isObject(head) && cleanEntryId(head.type) === 'tool')
157
141
  .map((head) => cleanEntryId(head.id)));
158
142
  }
159
143
 
160
- /**
161
- * App elements carry the portable record and a file manifest; file contents
162
- * stay in bundle.apps[] so the graph remains cheap to render.
163
- */
164
- function appElementData(entry) {
165
- return {
166
- sourceAppId: appEntryId(entry),
167
- manifest: cloneJson(entry.manifest || {}),
168
- app: cloneJson(entry.app || {}),
169
- files: (Array.isArray(entry.files) ? entry.files : []).map((file) => ({
170
- path: file.path,
171
- size: file.size,
172
- encoding: file.encoding,
173
- ...(file.executable ? { executable: true } : {}),
174
- })),
175
- totalBytes: entry.totalBytes || 0,
176
- skipped: cloneJson(entry.skipped || []),
177
- };
144
+ function assertUniqueRecords(label, records, idForRecord) {
145
+ const seen = new Set();
146
+ for (const record of records) {
147
+ const id = idForRecord(record);
148
+ if (!id) throw new Error(`${label} entries must include ids`);
149
+ if (seen.has(id)) throw new Error(`Bundle contains duplicate ${label} id: ${id}`);
150
+ seen.add(id);
151
+ }
152
+ }
153
+
154
+ function assertCanonicalBundle(bundle) {
155
+ if (!isObject(bundle)) throw new Error('bundle must be an object');
156
+ if (bundle.kind !== BUNDLE_KIND) {
157
+ throw new Error(`Unsupported bundle kind: ${bundle.kind || 'unknown'}`);
158
+ }
159
+ if (bundle.schemaVersion !== BUNDLE_SCHEMA_VERSION) {
160
+ throw new Error(`Unsupported bundle schema version: ${bundle.schemaVersion || 'unknown'}`);
161
+ }
162
+
163
+ const agents = Array.isArray(bundle.agents) ? bundle.agents : [];
164
+ const automations = Array.isArray(bundle.automations) ? bundle.automations : [];
165
+ const apps = Array.isArray(bundle.apps) ? bundle.apps : [];
166
+ const tools = Array.isArray(bundle.tools) ? bundle.tools : [];
167
+ const actions = Array.isArray(bundle.toolActions) ? bundle.toolActions : [];
168
+ const skills = Array.isArray(bundle.skills) ? bundle.skills : [];
169
+ const heads = Array.isArray(bundle.heads) ? bundle.heads : [];
170
+
171
+ assertUniqueRecords('agent', agents, agentEntryId);
172
+ assertUniqueRecords('automation', automations, automationEntryId);
173
+ assertUniqueRecords('app', apps, appEntryId);
174
+ assertUniqueRecords('tool', tools, toolEntryId);
175
+ assertUniqueRecords('tool action', actions, actionEntryId);
176
+ assertUniqueRecords('skill', skills, skillFingerprint);
177
+ assertUniqueRecords('head', heads, (head) => {
178
+ const type = cleanEntryId(head?.type);
179
+ const id = cleanEntryId(head?.id);
180
+ if (!HEAD_TYPES.has(type)) return '';
181
+ return `${type}:${id}`;
182
+ });
183
+ if (heads.length === 0) throw new Error('bundle must include at least one head');
184
+
185
+ const missingTools = uniqueStrings(bundle.requires?.missingTools);
186
+ if (missingTools.length > 0) {
187
+ throw new Error(`Bundle is missing tools: ${missingTools.join(', ')}`);
188
+ }
178
189
  }
179
190
 
180
191
  function buildFlatAgentBundleGraph(bundle) {
192
+ assertCanonicalBundle(bundle);
193
+
194
+ const agents = bundle.agents || [];
195
+ const automations = bundle.automations || [];
196
+ const apps = bundle.apps || [];
197
+ const tools = bundle.tools || [];
198
+ const actions = bundle.toolActions || [];
199
+ const skills = bundle.skills || [];
181
200
  const elements = new Map();
182
201
  const edges = new Map();
183
- const toolMap = new Map((Array.isArray(bundle.tools) ? bundle.tools : []).map((tool) => [tool.id, tool]));
184
- const toolActionMap = new Map((Array.isArray(bundle.toolActions) ? bundle.toolActions : []).map((action) => [action.id, action]));
185
- const systemToolIds = new Set(uniqueStrings(bundle.requires?.systemTools));
186
- const missingToolIds = new Set(uniqueStrings(bundle.requires?.missingTools));
187
- const skills = collectSkillRecords(bundle.agents || []);
188
-
189
- for (const entry of bundle.agents || []) {
190
- const currentAgentId = agentEntryId(entry);
191
- if (!currentAgentId) continue;
192
- pushGraphElement(elements, {
193
- id: graphElementId('agent', currentAgentId),
194
- type: 'agent',
195
- label: entry.agent?.name || currentAgentId,
196
- data: {
197
- sourceAgentId: currentAgentId,
198
- agent: cloneJson(entry.agent || {}),
199
- config: cloneJson(normalizeAgentConfig(entry.config || {})),
200
- },
201
- });
202
- }
202
+ const records = {
203
+ agent: new Map(agents.map((entry) => [agentEntryId(entry), entry])),
204
+ automation: new Map(automations.map((entry) => [automationEntryId(entry), entry])),
205
+ app: new Map(apps.map((entry) => [appEntryId(entry), entry])),
206
+ tool: new Map(tools.map((entry) => [toolEntryId(entry), entry])),
207
+ tool_action: new Map(actions.map((entry) => [actionEntryId(entry), entry])),
208
+ skill: new Map(skills.map((entry) => [skillElementId(entry), entry])),
209
+ };
203
210
 
204
- for (const entry of bundle.automations || []) {
205
- const currentAutomationId = automationEntryId(entry);
206
- if (!currentAutomationId) continue;
207
- pushGraphElement(elements, {
208
- id: graphElementId('automation', currentAutomationId),
209
- type: 'automation',
210
- label: entry.automation?.name || currentAutomationId,
211
- data: cloneJson(entry),
212
- });
213
- }
211
+ const addElement = (type, id, label) => {
212
+ const elementId = graphElementId(type, id);
213
+ elements.set(elementId, { id: elementId, type, label: label || id });
214
+ };
215
+ const addEdge = (fromType, fromId, toType, toId, kind) => {
216
+ const from = graphElementId(fromType, fromId);
217
+ const to = graphElementId(toType, toId);
218
+ const key = `${from}::${kind}::${to}`;
219
+ edges.set(key, { from, to, kind });
220
+ };
221
+ const requireRecord = (type, id, owner) => {
222
+ if (!records[type].has(id)) {
223
+ throw new Error(`${owner} references missing ${type.replace('_', ' ')}: ${id}`);
224
+ }
225
+ };
226
+ const addToolSelection = (ownerType, ownerId, selectedId) => {
227
+ if (records.tool_action.has(selectedId)) {
228
+ addEdge(ownerType, ownerId, 'tool_action', selectedId, 'uses-tool-action');
229
+ return;
230
+ }
231
+ requireRecord('tool', selectedId, `${ownerType} ${ownerId}`);
232
+ addEdge(ownerType, ownerId, 'tool', selectedId, 'uses-tool');
233
+ };
214
234
 
215
- for (const entry of bundle.apps || []) {
216
- const currentAppId = appEntryId(entry);
217
- if (!currentAppId) continue;
218
- pushGraphElement(elements, {
219
- id: graphElementId('app', currentAppId),
220
- type: 'app',
221
- label: entry.app?.name || currentAppId,
222
- data: appElementData(entry),
223
- });
235
+ for (const entry of agents) {
236
+ const id = agentEntryId(entry);
237
+ addElement('agent', id, entry.agent?.name);
224
238
  }
225
-
239
+ for (const entry of automations) {
240
+ const id = automationEntryId(entry);
241
+ addElement('automation', id, entry.automation?.name);
242
+ }
243
+ for (const entry of apps) {
244
+ const id = appEntryId(entry);
245
+ addElement('app', id, entry.app?.name);
246
+ }
247
+ for (const tool of tools) addElement('tool', toolEntryId(tool), tool.name);
248
+ for (const action of actions) addElement('tool_action', actionEntryId(action), action.name);
226
249
  for (const skill of skills) {
227
250
  const elementId = skillElementId(skill);
228
- if (!elementId) continue;
229
- pushGraphElement(elements, {
230
- id: elementId,
231
- type: 'skill',
232
- label: skillLabel(skill),
233
- data: typeof skill === 'string' ? { value: skill } : cloneJson(skill),
234
- });
251
+ elements.set(elementId, { id: elementId, type: 'skill', label: skillLabel(skill) });
235
252
  }
236
253
 
237
- const includedToolIds = new Set();
238
- const includedActionIds = new Set();
239
-
240
- for (const entry of bundle.automations || []) {
241
- const currentAutomationId = automationEntryId(entry);
242
- if (!currentAutomationId) continue;
243
- for (const toolId of uniqueStrings(entry.toolIds)) {
244
- includedToolIds.add(toolId);
245
- pushGraphEdge(edges, {
246
- from: graphElementId('automation', currentAutomationId),
247
- to: graphElementId('tool', toolId),
248
- kind: 'uses-tool',
249
- });
254
+ for (const entry of agents) {
255
+ const id = agentEntryId(entry);
256
+ const config = normalizeAgentConfig(entry.config || {});
257
+ for (const subagent of config.subagents || []) {
258
+ const subagentId = cleanEntryId(subagent?.agentId);
259
+ if (!subagentId) continue;
260
+ requireRecord('agent', subagentId, `agent ${id}`);
261
+ addEdge('agent', id, 'agent', subagentId, 'uses-subagent');
250
262
  }
251
- }
252
- // Tools that belong to an app render nested under it in the share sidebar;
253
- // the canonical tool record still appears exactly once.
254
- for (const entry of bundle.apps || []) {
255
- const currentAppId = appEntryId(entry);
256
- if (!currentAppId) continue;
257
- for (const toolId of uniqueStrings(entry.toolIds)) {
258
- includedToolIds.add(toolId);
259
- pushGraphEdge(edges, {
260
- from: graphElementId('app', currentAppId),
261
- to: graphElementId('tool', toolId),
262
- kind: 'uses-tool',
263
- });
264
- }
265
- }
266
- for (const entry of bundle.agents || []) {
267
- const currentAgentId = agentEntryId(entry);
268
- if (!currentAgentId) continue;
269
- const currentConfig = normalizeAgentConfig(entry.config || {});
270
-
271
- for (const subagent of currentConfig.subagents || []) {
272
- if (!subagent?.agentId) continue;
273
- pushGraphEdge(edges, {
274
- from: graphElementId('agent', currentAgentId),
275
- to: graphElementId('agent', subagent.agentId),
276
- kind: 'uses-subagent',
277
- });
263
+ for (const selectedId of uniqueStrings(config.loadout?.toolIds)) {
264
+ addToolSelection('agent', id, selectedId);
278
265
  }
279
-
280
- for (const selectedId of uniqueStrings(currentConfig.loadout?.toolIds)) {
281
- const selectedAction = toolActionMap.get(selectedId);
282
- if (selectedAction) {
283
- includedActionIds.add(selectedAction.id);
284
- includedToolIds.add(selectedAction.toolId);
285
- pushGraphEdge(edges, {
286
- from: graphElementId('agent', currentAgentId),
287
- to: graphElementId('tool_action', selectedAction.id),
288
- kind: 'uses-tool-action',
289
- });
290
- continue;
266
+ for (const skill of config.skills || []) {
267
+ const elementId = skillElementId(skill);
268
+ if (!records.skill.has(elementId)) {
269
+ throw new Error(`agent ${id} references missing skill: ${skillLabel(skill)}`);
291
270
  }
271
+ const skillId = elementId.slice('skill:'.length);
272
+ addEdge('agent', id, 'skill', skillId, 'uses-skill');
273
+ }
274
+ }
292
275
 
293
- includedToolIds.add(selectedId);
294
- pushGraphEdge(edges, {
295
- from: graphElementId('agent', currentAgentId),
296
- to: graphElementId('tool', selectedId),
297
- kind: 'uses-tool',
298
- });
276
+ for (const entry of automations) {
277
+ const id = automationEntryId(entry);
278
+ for (const selectedId of uniqueStrings(entry.toolIds)) {
279
+ addToolSelection('automation', id, selectedId);
299
280
  }
281
+ }
300
282
 
301
- for (const skill of currentConfig.skills || []) {
302
- const targetId = skillElementId(skill);
303
- if (!targetId) continue;
304
- pushGraphEdge(edges, {
305
- from: graphElementId('agent', currentAgentId),
306
- to: targetId,
307
- kind: 'uses-skill',
308
- });
283
+ for (const entry of apps) {
284
+ const id = appEntryId(entry);
285
+ for (const toolId of uniqueStrings(entry.toolIds)) {
286
+ requireRecord('tool', toolId, `app ${id}`);
287
+ addEdge('app', id, 'tool', toolId, 'provides-tool');
309
288
  }
310
289
  }
311
290
 
312
- // Every tool in bundle.tools gets an element — standalone tool heads are
313
- // not referenced by any agent or automation edge but must still resolve.
314
- const toolElementIds = new Set([...includedToolIds, ...toolMap.keys()]);
315
- for (const toolId of toolElementIds) {
316
- const tool = toolMap.get(toolId);
317
- pushGraphElement(elements, {
318
- id: graphElementId('tool', toolId),
319
- type: 'tool',
320
- label: tool?.name || toolId,
321
- data: tool
322
- ? cloneJson(tool)
323
- : {
324
- id: toolId,
325
- name: toolId,
326
- origin: systemToolIds.has(toolId) ? 'system' : missingToolIds.has(toolId) ? 'missing' : 'unknown',
327
- },
328
- });
291
+ for (const tool of tools) {
292
+ const toolId = toolEntryId(tool);
293
+ const appId = cleanEntryId(tool.appId);
294
+ if (appId) {
295
+ requireRecord('app', appId, `tool ${toolId}`);
296
+ addEdge('tool', toolId, 'app', appId, 'requires-app');
297
+ }
329
298
  }
330
299
 
331
- for (const action of Array.isArray(bundle.toolActions) ? bundle.toolActions : []) {
332
- if (!toolElementIds.has(action.toolId)) continue;
333
- includedActionIds.add(action.id);
334
- pushGraphElement(elements, {
335
- id: graphElementId('tool_action', action.id),
336
- type: 'tool_action',
337
- label: action.name || action.id,
338
- data: cloneJson(action),
339
- });
340
- pushGraphEdge(edges, {
341
- from: graphElementId('tool_action', action.id),
342
- to: graphElementId('tool', action.toolId),
343
- kind: 'action-of',
344
- });
300
+ for (const action of actions) {
301
+ const actionId = actionEntryId(action);
302
+ const toolId = cleanEntryId(action.toolId);
303
+ requireRecord('tool', toolId, `tool action ${actionId}`);
304
+ addEdge('tool', toolId, 'tool_action', actionId, 'has-action');
305
+ addEdge('tool_action', actionId, 'tool', toolId, 'action-of');
306
+ const appId = cleanEntryId(action.appId);
307
+ if (appId) {
308
+ requireRecord('app', appId, `tool action ${actionId}`);
309
+ addEdge('tool_action', actionId, 'app', appId, 'requires-app');
310
+ }
345
311
  }
346
312
 
313
+ const headIds = bundle.heads.map((head) => {
314
+ const elementId = graphElementId(cleanEntryId(head.type), cleanEntryId(head.id));
315
+ if (!elements.has(elementId)) throw new Error(`bundle head references missing element: ${elementId}`);
316
+ return elementId;
317
+ });
318
+
347
319
  return {
348
- headIds: bundleHeadIds(bundle, elements),
320
+ headIds,
349
321
  elements: [...elements.values()].sort(compareGraphElements),
350
322
  edges: [...edges.values()].sort(compareGraphEdges),
351
323
  };
352
324
  }
353
325
 
354
- /**
355
- * Heads are the user-facing roots of the bundle. Explicit bundle.heads win;
356
- * otherwise fall back to the legacy single agent root plus every automation
357
- * and app entry (those are always top-level).
358
- */
359
- function bundleHeadIds(bundle, elements) {
360
- const headIds = [];
361
- const explicitHeads = Array.isArray(bundle.heads) ? bundle.heads : [];
362
- for (const head of explicitHeads) {
363
- const type = cleanEntryId(head?.type);
364
- const id = cleanEntryId(head?.id);
365
- if (!type || !id) continue;
366
- const elementId = graphElementId(type, id);
367
- if (elements.has(elementId) && !headIds.includes(elementId)) headIds.push(elementId);
326
+ function reachableElementIds(bundle) {
327
+ const outgoing = new Map();
328
+ for (const edge of bundle.edges || []) {
329
+ const targets = outgoing.get(edge.from) || [];
330
+ targets.push(edge.to);
331
+ outgoing.set(edge.from, targets);
368
332
  }
369
- if (headIds.length > 0) return headIds;
370
-
371
- const firstAgentId = agentEntryId(bundle.agents?.[0]);
372
- const rootAgentId = cleanEntryId(bundle.rootAgentId) || firstAgentId;
373
- if (rootAgentId && elements.has(graphElementId('agent', rootAgentId))) {
374
- headIds.push(graphElementId('agent', rootAgentId));
375
- }
376
- for (const entry of bundle.automations || []) {
377
- const id = automationEntryId(entry);
378
- if (id) headIds.push(graphElementId('automation', id));
379
- }
380
- for (const entry of bundle.apps || []) {
381
- const id = appEntryId(entry);
382
- if (id) headIds.push(graphElementId('app', id));
333
+ const reachable = new Set();
334
+ const pending = [...(bundle.headIds || [])];
335
+ while (pending.length > 0) {
336
+ const id = pending.pop();
337
+ if (reachable.has(id)) continue;
338
+ reachable.add(id);
339
+ pending.push(...(outgoing.get(id) || []));
383
340
  }
384
- return [...new Set(headIds)];
341
+ return reachable;
385
342
  }
386
343
 
387
344
  function assertClosedFlatBundleGraph(bundle) {
388
345
  if (!Array.isArray(bundle.headIds) || bundle.headIds.length === 0) {
389
346
  throw new Error('bundle graph must include at least one head');
390
347
  }
391
-
392
348
  const elementIds = new Set();
393
349
  for (const element of Array.isArray(bundle.elements) ? bundle.elements : []) {
394
- if (!isObject(element) || typeof element.id !== 'string' || !element.id.trim()) {
395
- throw new Error('bundle graph elements must include non-empty ids');
350
+ if (!isObject(element) || !cleanEntryId(element.id) || !cleanEntryId(element.type)) {
351
+ throw new Error('bundle graph elements must include id and type');
396
352
  }
397
- if (elementIds.has(element.id)) {
398
- throw new Error(`bundle graph contains duplicate element id: ${element.id}`);
353
+ if (Object.prototype.hasOwnProperty.call(element, 'data')) {
354
+ throw new Error(`bundle graph element must not copy canonical payload data: ${element.id}`);
399
355
  }
356
+ if (elementIds.has(element.id)) throw new Error(`bundle graph contains duplicate element id: ${element.id}`);
400
357
  elementIds.add(element.id);
401
358
  }
402
-
403
359
  for (const headId of bundle.headIds) {
404
- if (typeof headId !== 'string' || !elementIds.has(headId)) {
405
- throw new Error(`bundle graph head is missing element: ${headId}`);
406
- }
360
+ if (!elementIds.has(headId)) throw new Error(`bundle graph head is missing element: ${headId}`);
407
361
  }
408
-
362
+ const edgeKeys = new Set();
409
363
  for (const edge of Array.isArray(bundle.edges) ? bundle.edges : []) {
410
- if (!isObject(edge) || typeof edge.from !== 'string' || typeof edge.to !== 'string' || typeof edge.kind !== 'string') {
364
+ if (!isObject(edge) || !cleanEntryId(edge.from) || !cleanEntryId(edge.to) || !cleanEntryId(edge.kind)) {
411
365
  throw new Error('bundle graph edges must include from, to, and kind');
412
366
  }
413
367
  if (!elementIds.has(edge.from)) throw new Error(`bundle graph edge source is missing element: ${edge.from}`);
414
368
  if (!elementIds.has(edge.to)) throw new Error(`bundle graph edge target is missing element: ${edge.to}`);
369
+ const key = `${edge.from}::${edge.kind}::${edge.to}`;
370
+ if (edgeKeys.has(key)) throw new Error(`bundle graph contains duplicate edge: ${key}`);
371
+ edgeKeys.add(key);
372
+ }
373
+ const reachable = reachableElementIds(bundle);
374
+ const unreachable = [...elementIds].filter((id) => !reachable.has(id));
375
+ if (unreachable.length > 0) {
376
+ throw new Error(`bundle graph contains unreachable elements: ${unreachable.join(', ')}`);
415
377
  }
416
378
  }
417
379
 
418
380
  function withBundleGraph(bundle) {
419
381
  const graph = buildFlatAgentBundleGraph(bundle);
420
- const nextBundle = {
421
- ...bundle,
422
- headIds: graph.headIds,
423
- elements: graph.elements,
424
- edges: graph.edges,
425
- };
382
+ const rootAgentId = cleanEntryId(bundle.heads.find((head) => head?.type === 'agent')?.id) || null;
383
+ const nextBundle = { ...bundle, rootAgentId, ...graph };
426
384
  assertClosedFlatBundleGraph(nextBundle);
427
385
  return nextBundle;
428
386
  }
429
387
 
388
+ function assertBundleGraphMatches(bundle) {
389
+ const expected = withBundleGraph({ ...bundle, headIds: undefined, elements: undefined, edges: undefined });
390
+ for (const field of ['rootAgentId', 'headIds', 'elements', 'edges']) {
391
+ if (JSON.stringify(bundle[field]) !== JSON.stringify(expected[field])) {
392
+ throw new Error(`bundle ${field} does not match its canonical records and connections`);
393
+ }
394
+ }
395
+ return bundle;
396
+ }
397
+
430
398
  module.exports = {
431
399
  BUNDLE_KIND,
432
400
  BUNDLE_SCHEMA_VERSION,
433
- LEGACY_AGENT_BUNDLE_KIND,
434
401
  addUnique,
435
402
  agentEntryId,
436
403
  appEntryId,
404
+ assertBundleGraphMatches,
405
+ assertCanonicalBundle,
437
406
  assertClosedFlatBundleGraph,
438
407
  automationEntryId,
439
408
  buildFlatAgentBundleGraph,
@@ -442,6 +411,8 @@ module.exports = {
442
411
  collectSkillRecords,
443
412
  isObject,
444
413
  nowIso,
414
+ reachableElementIds,
415
+ skillElementId,
445
416
  standaloneToolIds,
446
417
  uniqueStrings,
447
418
  withBundleGraph,