@newheap/platform-ai-chat 0.18.0 → 0.19.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.
@@ -1,6 +1,6 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, signal, Injectable, computed, DestroyRef, makeEnvironmentProviders } from '@angular/core';
3
- import { NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
3
+ import { NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS, NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION, NH_ASSISTANT_AGENT_PACKAGE_FORMAT, NH_ASSISTANT_PACKAGE_LOCK_ALL, NH_ASSISTANT_PACKAGE_LOCK_FIELDS, NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES, NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
4
4
  import { Observable, Subject } from 'rxjs';
5
5
 
6
6
  /**
@@ -68,6 +68,8 @@ const statusTable = {
68
68
  'assistant-disabled',
69
69
  'assistant-conversation-not-found',
70
70
  'assistant-agent-not-found',
71
+ 'assistant-metadata-entry-not-found',
72
+ 'assistant-instructions-asset-not-found',
71
73
  'assistant-approval-not-found',
72
74
  'assistant-share-link-invalid',
73
75
  'assistant-participant-not-found',
@@ -84,6 +86,8 @@ const statusTable = {
84
86
  'assistant-artifact-not-found',
85
87
  'assistant-trigger-not-found',
86
88
  'assistant-consultation-not-found',
89
+ 'assistant-handoff-not-found',
90
+ 'assistant-package-not-linked',
87
91
  'assistant-not-found',
88
92
  'assistant-context-not-found',
89
93
  'assistant-mcp-server-not-found',
@@ -114,6 +118,9 @@ const statusTable = {
114
118
  'assistant-consult-proposal-moved',
115
119
  'assistant-consultation-in-code',
116
120
  'assistant-consultations-not-allowed',
121
+ 'assistant-handoffs-not-allowed',
122
+ 'assistant-handoff-in-code',
123
+ 'assistant-shadow-variants-not-allowed',
117
124
  'assistant-skill-in-code',
118
125
  'assistant-trigger-in-code',
119
126
  'assistant-skill-in-use',
@@ -131,6 +138,10 @@ const statusTable = {
131
138
  'assistant-agent-exists',
132
139
  'assistant-code-agent-not-deletable',
133
140
  'assistant-agent-not-code',
141
+ 'assistant-metadata-read-only',
142
+ 'assistant-package-refused',
143
+ 'assistant-package-locked',
144
+ 'assistant-agent-instructions-unavailable',
134
145
  'assistant-mcp-server-exists',
135
146
  'assistant-mcp-headless-not-allowed',
136
147
  'assistant-mcp-consent-required',
@@ -144,6 +155,7 @@ const statusTable = {
144
155
  ],
145
156
  413: [
146
157
  'assistant-webhook-payload-too-large',
158
+ 'assistant-package-too-large',
147
159
  'assistant-file-too-large'
148
160
  ],
149
161
  415: [
@@ -169,6 +181,7 @@ const statusTable = {
169
181
  'assistant-proposal-check-failed',
170
182
  'assistant-trigger-rolled-back',
171
183
  'assistant-mcp-secret-unavailable',
184
+ 'assistant-instructions-asset-unavailable',
172
185
  'assistant-file-unavailable',
173
186
  'assistant-file-upload-failed'
174
187
  ]
@@ -215,6 +228,22 @@ function mockFailure(code, errors) {
215
228
  function mockValidationFailure(field) {
216
229
  return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
217
230
  }
231
+ /**
232
+ * Copies the stored state of a mock area: the returned function puts every map back as it was, so
233
+ * an agent package import that one part refuses leaves nothing behind, as the server's one
234
+ * transaction does, and a preview tries the import without keeping it.
235
+ */
236
+ function mockSnapshot(...maps) {
237
+ const copies = maps.map(map => structuredClone(map));
238
+ return () => {
239
+ maps.forEach((map, index) => {
240
+ map.clear();
241
+ for (const [key, value] of copies[index]) {
242
+ map.set(key, value);
243
+ }
244
+ });
245
+ };
246
+ }
218
247
  /** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
219
248
  /**
220
249
  * The warnings of an agent or skill that classifies its model calls with `classification` on
@@ -397,8 +426,9 @@ class NhAssistantMockAdminAgents {
397
426
  const agents = admin.agents
398
427
  ?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
399
428
  // How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
400
- for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, ...input } of agents) {
401
- const agentShadow = shadowOf !== undefined || shadow === true;
429
+ for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
430
+ const variant = typeof shadowOf === 'string';
431
+ const agentShadow = variant || shadow === true;
402
432
  const initial = this.toAgent(input, source, 1, false);
403
433
  this.agents.set(input.id, {
404
434
  agent: {
@@ -407,7 +437,7 @@ class NhAssistantMockAdminAgents {
407
437
  ...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
408
438
  knownSubjectTypes: knownSubjectTypes ?? [],
409
439
  shadowOf: shadowOf ?? null,
410
- shadow: shadowState(agentShadow, shadowOf !== undefined || shadow === true, null, shadowOf !== undefined),
440
+ shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
411
441
  serviceAccount: serviceAccount === undefined
412
442
  ? null
413
443
  : { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
@@ -425,7 +455,7 @@ class NhAssistantMockAdminAgents {
425
455
  dailyTokens: null,
426
456
  cancelledRuns: 0,
427
457
  startedRuns: 0,
428
- shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
458
+ shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
429
459
  }))
430
460
  },
431
461
  codeDefinition: source === 'code' ? structuredClone(input) : null,
@@ -439,6 +469,18 @@ class NhAssistantMockAdminAgents {
439
469
  find(agentId) {
440
470
  return this.agents.get(agentId)?.agent;
441
471
  }
472
+ /**
473
+ * A copy of the agents and their replays; the returned function puts it back, for an import of an
474
+ * agent package that fails as a whole and for its preview.
475
+ */
476
+ snapshot() {
477
+ return mockSnapshot(this.agents, this.replays);
478
+ }
479
+ /** The definition in code of an agent in code, as `reset` restores it; `null` for an agent created here. */
480
+ codeDefinition(agentId) {
481
+ const definition = this.agents.get(agentId)?.codeDefinition;
482
+ return definition ? structuredClone(definition) : null;
483
+ }
442
484
  has(agentId) {
443
485
  return this.agents.has(agentId);
444
486
  }
@@ -446,6 +488,18 @@ class NhAssistantMockAdminAgents {
446
488
  all() {
447
489
  return [...this.agents.values()].map(stored => stored.agent);
448
490
  }
491
+ /**
492
+ * Makes the next version of an agent, as a change of the metadata of an agent created here does on
493
+ * the server; nothing else of the agent changes.
494
+ */
495
+ nextVersion(agentId) {
496
+ const stored = this.agents.get(agentId);
497
+ if (!stored) {
498
+ throw new Error(`The mock has no agent '${agentId}'.`);
499
+ }
500
+ stored.agent = { ...stored.agent, version: stored.agent.version + 1, updatedAt: this.now() };
501
+ return stored.agent;
502
+ }
449
503
  /** Removes a deleted MCP server from the agents it was assigned to. */
450
504
  removeServer(serverId) {
451
505
  for (const agent of this.agents.values()) {
@@ -480,7 +534,7 @@ class NhAssistantMockAdminAgents {
480
534
  agentsEndpoint(method, id, action, body, length) {
481
535
  if (id === undefined) {
482
536
  if (method === 'GET') {
483
- return { status: 200, body: [...this.agents.values()].map(stored => stored.agent) };
537
+ return { status: 200, body: [...this.agents.values()].map(stored => this.withInstructions(stored.agent)) };
484
538
  }
485
539
  if (method !== 'POST') {
486
540
  return { status: 405 };
@@ -493,7 +547,21 @@ class NhAssistantMockAdminAgents {
493
547
  if (this.agents.has(input.id)) {
494
548
  return mockFailure('assistant-agent-exists');
495
549
  }
496
- const agent = this.toAgent(input, 'admin', 1, false);
550
+ const shadowOf = typeof input.shadowOf === 'string' && input.shadowOf.trim().length > 0 ? input.shadowOf.trim() : null;
551
+ // Without the application's ceiling the mock creates no data variants, as the server refuses them.
552
+ const refused = shadowOf === null
553
+ ? null
554
+ : this.rules.shadowVariantRefusal
555
+ ? this.rules.shadowVariantRefusal({ ...input, shadowOf })
556
+ : mockFailure('assistant-shadow-variants-not-allowed');
557
+ if (refused) {
558
+ return refused;
559
+ }
560
+ const created = this.toAgent(input, 'admin', 1, false);
561
+ // A data shadow variant always works in shadow and is never offered in the chat.
562
+ const agent = shadowOf === null
563
+ ? created
564
+ : { ...created, shadowOf, offeredInChat: false, shadow: shadowState(true, false, null, true) };
497
565
  this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
498
566
  return { status: 201, body: agent };
499
567
  }
@@ -549,7 +617,9 @@ class NhAssistantMockAdminAgents {
549
617
  return mockFailure('assistant-version-conflict');
550
618
  }
551
619
  const { expectedVersion, ...definition } = input;
552
- stored.agent = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
620
+ const updated = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
621
+ // A data shadow variant keeps its live agent and stays out of the chat.
622
+ stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
553
623
  return { status: 200, body: stored.agent };
554
624
  }
555
625
  if (method === 'DELETE') {
@@ -966,6 +1036,22 @@ class NhAssistantMockAdminAgents {
966
1036
  if (typeof input?.instructions === 'string' && input.instructions.length > mockMaxInstructions) {
967
1037
  return mockFailure('assistant-instructions-too-long');
968
1038
  }
1039
+ // Only an agent created here takes its instructions from an asset, never next to inline text.
1040
+ if (input?.instructionsAsset) {
1041
+ const errors = {};
1042
+ if (source !== 'admin' || !this.rules.instructionAssets) {
1043
+ errors['instructionsAsset'] = ['not-allowed'];
1044
+ }
1045
+ else {
1046
+ this.rules.instructionAssets().check(input.instructionsAsset, errors);
1047
+ }
1048
+ if (typeof input.instructions === 'string' && input.instructions.trim().length > 0) {
1049
+ errors['instructions'] = ['not-allowed'];
1050
+ }
1051
+ if (Object.keys(errors).length > 0) {
1052
+ return mockFailure('assistant-validation', errors);
1053
+ }
1054
+ }
969
1055
  if (!this.validAgent(input)) {
970
1056
  return mockValidationFailure();
971
1057
  }
@@ -1010,8 +1096,14 @@ class NhAssistantMockAdminAgents {
1010
1096
  */
1011
1097
  toAgent(input, source, version, isOverridden, previous) {
1012
1098
  // Only a new agent starts off, created that way here or with StartsOff() in code.
1013
- const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
1099
+ // Metadata is not part of the definition; its values live with the mock's agent metadata.
1100
+ const { isEnabled, startsOff, startsInShadow, profile, dataClassification, metadata, instructionsAsset, ...definition } = structuredClone(input);
1014
1101
  const createdOff = previous === undefined && startsOff === true;
1102
+ // Only a new agent created here starts in shadow, as an override that applies once it has run settings.
1103
+ const createdInShadow = previous === undefined && source === 'admin' && startsInShadow === true;
1104
+ // The agent shows the asset's text read-only, or none while it does not resolve.
1105
+ const reference = source === 'admin' && instructionsAsset ? instructionsAsset : null;
1106
+ const assetProblem = reference ? this.instructionsProblemOf(reference) : null;
1015
1107
  const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
1016
1108
  const classification = chosenClassification(dataClassification);
1017
1109
  return this.withWarnings({
@@ -1026,7 +1118,10 @@ class NhAssistantMockAdminAgents {
1026
1118
  version,
1027
1119
  source,
1028
1120
  isOverridden,
1029
- instructionsHash: mockHashText(input.instructions),
1121
+ instructions: reference ? this.rules.instructionAssets?.().textOf(reference) ?? '' : definition.instructions,
1122
+ instructionsHash: reference ? reference.hash : mockHashText(input.instructions),
1123
+ instructionsAsset: reference,
1124
+ instructionsProblem: assetProblem,
1030
1125
  updatedAt: this.now(),
1031
1126
  runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
1032
1127
  stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
@@ -1035,7 +1130,9 @@ class NhAssistantMockAdminAgents {
1035
1130
  waitingInputs: previous?.waitingInputs ?? 0,
1036
1131
  skills: previous?.skills ?? [],
1037
1132
  shadowOf: previous?.shadowOf ?? null,
1038
- shadow: previous?.shadow ?? shadowState(false, false, null, false),
1133
+ shadow: previous?.shadow ?? (createdInShadow
1134
+ ? { ...shadowState(true, false, true, false), changedAt: this.now(), changedBy: 'current-user' }
1135
+ : shadowState(false, false, null, false)),
1039
1136
  serviceAccount: previous?.serviceAccount ?? null,
1040
1137
  offBeforeStop: previous?.offBeforeStop ?? false,
1041
1138
  // Code decides for its agents; an agent created here keeps its choice when it is omitted.
@@ -1049,6 +1146,26 @@ class NhAssistantMockAdminAgents {
1049
1146
  knownSubjectTypes: previous?.knownSubjectTypes ?? []
1050
1147
  });
1051
1148
  }
1149
+ /** Why the asset an agent created here names does not resolve now, or `null` when it does. */
1150
+ instructionsProblemOf(reference) {
1151
+ const assets = this.rules.instructionAssets?.();
1152
+ return assets ? assets.problemOf(reference) : 'no-asset-source';
1153
+ }
1154
+ /**
1155
+ * The agent as a read finds it now: the text of the asset it names and the problem when that no
1156
+ * longer resolves, as the server checks it at every load.
1157
+ */
1158
+ withInstructions(agent) {
1159
+ if (!agent.instructionsAsset) {
1160
+ return agent;
1161
+ }
1162
+ const problem = this.instructionsProblemOf(agent.instructionsAsset);
1163
+ return {
1164
+ ...agent,
1165
+ instructions: problem === null ? this.rules.instructionAssets?.().textOf(agent.instructionsAsset) ?? '' : '',
1166
+ instructionsProblem: problem
1167
+ };
1168
+ }
1052
1169
  /** The agent with the warnings the server reports for it, from its profile and classification. */
1053
1170
  withWarnings(agent) {
1054
1171
  return {
@@ -2335,6 +2452,321 @@ function kebab(value) {
2335
2452
  .toLowerCase();
2336
2453
  }
2337
2454
 
2455
+ const maxHandOffs = 20;
2456
+ const maxSources = 20;
2457
+ const maxDescription$3 = 500;
2458
+ const maxPerDay = 100;
2459
+ const defaultPerDay = 5;
2460
+ const defaultVariants = 3;
2461
+ /**
2462
+ * In-memory hand-offs defined as data and shadow variants of the mock API (S9), within the scenario's
2463
+ * `handOffsAsData` and `shadowVariantsAsData` as the ceilings in code: saving checks a hand-off as the
2464
+ * server checks it on every save and load, and `GET` reports a stored hand-off that no longer fits,
2465
+ * for example after its source lost its run settings, out of effect with its problem. A data variant
2466
+ * is created with `POST admin/agents` and `shadowOf`, which `shadowVariantRefusal` checks.
2467
+ */
2468
+ class NhAssistantMockHandOffs {
2469
+ constructor(scenario, agents, now) {
2470
+ this.scenario = scenario;
2471
+ this.agents = agents;
2472
+ this.now = now;
2473
+ this.stored = new Map();
2474
+ }
2475
+ /**
2476
+ * A copy of the hand-offs defined as data; the returned function puts it back, for an import of
2477
+ * an agent package that fails as a whole and for its preview.
2478
+ */
2479
+ snapshot() {
2480
+ return mockSnapshot(this.stored);
2481
+ }
2482
+ /**
2483
+ * `GET admin/agents/{id}/hand-offs`, `PUT` and `DELETE admin/agents/{id}/hand-offs/{subjectType}`
2484
+ * and `GET admin/agents/{id}/shadow-variants`; `segments` start after the agent id. `null` for
2485
+ * another route.
2486
+ */
2487
+ handle(method, agentId, segments, body, query) {
2488
+ const [area, subjectType] = segments;
2489
+ if (area === 'shadow-variants' && segments.length === 1) {
2490
+ const agent = this.agents.find(agentId);
2491
+ if (!agent) {
2492
+ return mockFailure('assistant-agent-not-found');
2493
+ }
2494
+ return method === 'GET' ? { status: 200, body: this.variantsOf(agent) } : { status: 405 };
2495
+ }
2496
+ if (area !== 'hand-offs' || segments.length > 2) {
2497
+ return null;
2498
+ }
2499
+ const agent = this.agents.find(agentId);
2500
+ if (!agent) {
2501
+ return mockFailure('assistant-agent-not-found');
2502
+ }
2503
+ if (subjectType === undefined) {
2504
+ return method === 'GET' ? { status: 200, body: this.handOffsOf(agent) } : { status: 405 };
2505
+ }
2506
+ if (method === 'PUT') {
2507
+ return this.save(agent, subjectType, body);
2508
+ }
2509
+ if (method === 'DELETE') {
2510
+ return this.remove(agent, subjectType, query);
2511
+ }
2512
+ return { status: 405 };
2513
+ }
2514
+ /** Whether the agent accepts a hand-off defined as data, which keeps its run settings in use. */
2515
+ acceptsDataHandOffs(agentId) {
2516
+ return [...this.stored.values()].some(handOff => handOff.agentId === agentId);
2517
+ }
2518
+ /**
2519
+ * Why `input` may not create a data shadow variant, as `POST admin/agents` with `shadowOf` answers,
2520
+ * or `null`.
2521
+ */
2522
+ shadowVariantRefusal(input) {
2523
+ const liveId = input.shadowOf ?? null;
2524
+ if (liveId === null) {
2525
+ return null;
2526
+ }
2527
+ if (liveId === input.id) {
2528
+ return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
2529
+ }
2530
+ const live = this.agents.find(liveId);
2531
+ if (!this.allowsVariantsOf(liveId, live)) {
2532
+ return mockFailure('assistant-shadow-variants-not-allowed');
2533
+ }
2534
+ if (!live || !this.agents.isConfigured(live)) {
2535
+ return mockFailure('assistant-validation', { shadowOf: ['not-found'] });
2536
+ }
2537
+ if (!isLive(live)) {
2538
+ return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
2539
+ }
2540
+ return this.dataVariantsOf(liveId).length >= this.maxVariants()
2541
+ ? mockFailure('assistant-configuration-limit')
2542
+ : null;
2543
+ }
2544
+ handOffsOf(agent) {
2545
+ return {
2546
+ agentId: agent.id,
2547
+ codeHandOffs: this.codeHandOffsOf(agent.id),
2548
+ dataHandOffs: [...this.stored.values()]
2549
+ .filter(item => item.agentId === agent.id)
2550
+ .sort((first, second) => first.subjectType.localeCompare(second.subjectType))
2551
+ .map(({ agentId, ...item }) => {
2552
+ const problem = this.problemOf(agent, item.subjectType, item.sources, item.maxPerSubjectPerDay);
2553
+ return { ...item, inEffect: problem === null, problem };
2554
+ }),
2555
+ ceiling: this.ceilingOf(agent),
2556
+ configured: this.agents.isConfigured(agent)
2557
+ };
2558
+ }
2559
+ save(agent, subjectType, request) {
2560
+ const ceiling = this.ceilingOf(agent);
2561
+ if (!ceiling) {
2562
+ return mockFailure('assistant-handoffs-not-allowed');
2563
+ }
2564
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2565
+ return mockFailure('assistant-handoff-in-code');
2566
+ }
2567
+ if (!this.agents.isConfigured(agent)) {
2568
+ return mockFailure('assistant-agent-not-configured');
2569
+ }
2570
+ const sources = Array.isArray(request?.sources) ? request.sources.map(source => String(source).trim()) : [];
2571
+ const description = typeof request?.description === 'string' && request.description.trim().length > 0 ? request.description.trim() : null;
2572
+ const limit = request?.maxPerSubjectPerDay ?? Math.min(defaultPerDay, ceiling.maxPerSubjectPerDay);
2573
+ const reason = typeof request?.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
2574
+ const errors = {};
2575
+ if (sources.length === 0) {
2576
+ errors['sources'] = ['required'];
2577
+ }
2578
+ else if (sources.length > maxSources) {
2579
+ errors['sources'] = ['too-long'];
2580
+ }
2581
+ else if (sources.some(source => !mockDashCase.test(source)) || new Set(sources).size !== sources.length || sources.includes(agent.id)) {
2582
+ errors['sources'] = ['invalid'];
2583
+ }
2584
+ if (description !== null && description.length > maxDescription$3) {
2585
+ errors['description'] = ['too-long'];
2586
+ }
2587
+ if (!Number.isInteger(limit) || limit < 1 || limit > maxPerDay) {
2588
+ errors['maxPerSubjectPerDay'] = ['invalid'];
2589
+ }
2590
+ if (reason !== null && reason.length > mockMaxStateReason) {
2591
+ errors['reason'] = ['too-long'];
2592
+ }
2593
+ if (Object.keys(errors).length > 0) {
2594
+ return mockFailure('assistant-validation', errors);
2595
+ }
2596
+ // The same check as every load, refused here instead of left out of effect.
2597
+ const problem = this.problemOf(agent, subjectType, sources, limit);
2598
+ switch (problem) {
2599
+ case null:
2600
+ break;
2601
+ case 'outside-ceiling':
2602
+ return mockFailure('assistant-validation', ceiling.subjectTypes.some(item => item.subjectType === subjectType)
2603
+ ? { sources: ['not-allowed'] }
2604
+ : { subjectType: ['not-allowed'] });
2605
+ case 'unknown-source':
2606
+ return mockFailure('assistant-validation', { sources: ['not-found'] });
2607
+ case 'not-worked-on':
2608
+ return mockFailure('assistant-validation', { subjectType: ['invalid'] });
2609
+ case 'limit-above-ceiling':
2610
+ return mockFailure('assistant-validation', { maxPerSubjectPerDay: ['not-allowed'] });
2611
+ default:
2612
+ return mockFailure('assistant-validation', { sources: ['invalid'] });
2613
+ }
2614
+ const key = `${agent.id}/${subjectType}`;
2615
+ const existing = this.stored.get(key);
2616
+ if ((existing?.version ?? null) !== (request?.expectedVersion ?? null)) {
2617
+ return mockFailure('assistant-version-conflict');
2618
+ }
2619
+ if (!existing && [...this.stored.values()].filter(item => item.agentId === agent.id).length >= maxHandOffs) {
2620
+ return mockFailure('assistant-configuration-limit');
2621
+ }
2622
+ const now = this.now();
2623
+ const saved = {
2624
+ agentId: agent.id,
2625
+ subjectType,
2626
+ version: (existing?.version ?? 0) + 1,
2627
+ sources,
2628
+ description,
2629
+ notifySource: request?.notifySource === true,
2630
+ maxPerSubjectPerDay: limit,
2631
+ toolId: `assistant.handoff.${agent.id}.${subjectType}`,
2632
+ changedAt: now,
2633
+ changedBy: 'current-user',
2634
+ reason,
2635
+ inEffect: true,
2636
+ problem: null,
2637
+ createdAt: existing?.createdAt ?? now,
2638
+ createdBy: existing?.createdBy ?? 'current-user'
2639
+ };
2640
+ this.stored.set(key, saved);
2641
+ const { agentId, ...body } = saved;
2642
+ return { status: 200, body };
2643
+ }
2644
+ remove(agent, subjectType, query) {
2645
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2646
+ return mockFailure('assistant-handoff-in-code');
2647
+ }
2648
+ const key = `${agent.id}/${subjectType}`;
2649
+ const existing = this.stored.get(key);
2650
+ if (!existing) {
2651
+ return mockFailure('assistant-handoff-not-found');
2652
+ }
2653
+ const expected = query.get('expectedVersion');
2654
+ if (expected !== null && Number(expected) !== existing.version) {
2655
+ return mockFailure('assistant-version-conflict');
2656
+ }
2657
+ this.stored.delete(key);
2658
+ return { status: 204 };
2659
+ }
2660
+ /**
2661
+ * The problem a hand-off would have, as the server checks it on save and on every load: a ceiling
2662
+ * that connects the agent about the type, a type it works on, sources with a run configuration
2663
+ * that the ceiling allows, and a limit within the ceiling's.
2664
+ */
2665
+ problemOf(agent, subjectType, sources, limit) {
2666
+ const rules = this.rulesFor(agent, subjectType);
2667
+ if (!this.scenario.handOffsAsData) {
2668
+ return 'outside-ceiling';
2669
+ }
2670
+ if (!this.agents.isConfigured(agent)) {
2671
+ return 'unknown-agent';
2672
+ }
2673
+ if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
2674
+ return 'conflicts-with-code';
2675
+ }
2676
+ if (!mockWorksOnCovers(agent, subjectType)) {
2677
+ return 'not-worked-on';
2678
+ }
2679
+ const known = sources.map(source => this.agents.find(source));
2680
+ if (known.some(source => !source || !this.agents.isConfigured(source))) {
2681
+ return 'unknown-source';
2682
+ }
2683
+ if (known.some(source => !rules.some(rule => this.allowsSource(rule, source)))) {
2684
+ return 'outside-ceiling';
2685
+ }
2686
+ return limit > this.maxPerDay() ? 'limit-above-ceiling' : null;
2687
+ }
2688
+ ceilingOf(agent) {
2689
+ const rules = (this.scenario.handOffsAsData?.rules ?? []).filter(rule => this.allowsTarget(rule, agent));
2690
+ if (rules.length === 0) {
2691
+ return null;
2692
+ }
2693
+ const configured = this.agents.all().filter(candidate => candidate.id !== agent.id && this.agents.isConfigured(candidate));
2694
+ const types = [...new Set(rules.map(rule => rule.subjectType))].sort();
2695
+ return {
2696
+ subjectTypes: types.map(subjectType => {
2697
+ const forType = rules.filter(rule => rule.subjectType === subjectType);
2698
+ return {
2699
+ subjectType,
2700
+ sources: configured.filter(candidate => forType.some(rule => this.allowsSource(rule, candidate))).map(candidate => candidate.id).sort(),
2701
+ sourcesCreatedInAdministration: forType.some(rule => rule.fromAgentsCreatedInAdministration === true)
2702
+ };
2703
+ }),
2704
+ maxPerSubjectPerDay: this.maxPerDay(),
2705
+ maxHandOffs,
2706
+ maxSources,
2707
+ maxDescriptionLength: maxDescription$3
2708
+ };
2709
+ }
2710
+ rulesFor(agent, subjectType) {
2711
+ return (this.scenario.handOffsAsData?.rules ?? []).filter(rule => rule.subjectType === subjectType && this.allowsTarget(rule, agent));
2712
+ }
2713
+ allowsTarget(rule, agent) {
2714
+ return (rule.to ?? []).includes(agent.id) || (rule.toAgentsCreatedInAdministration === true && agent.source === 'admin');
2715
+ }
2716
+ allowsSource(rule, agent) {
2717
+ return (rule.from ?? []).includes(agent.id) || (rule.fromAgentsCreatedInAdministration === true && agent.source === 'admin');
2718
+ }
2719
+ maxPerDay() {
2720
+ return this.scenario.handOffsAsData?.maxPerSubjectPerDay ?? maxPerDay;
2721
+ }
2722
+ codeHandOffsOf(agentId) {
2723
+ const agent = (this.scenario.agents ?? []).find(item => item.id === agentId && item.source === 'code');
2724
+ return (agent?.codeHandOffs ?? []).map(item => ({
2725
+ subjectType: item.subjectType,
2726
+ sources: [...item.sources],
2727
+ description: item.description ?? null,
2728
+ notifySource: item.notifySource ?? false,
2729
+ maxPerSubjectPerDay: item.maxPerSubjectPerDay ?? defaultPerDay,
2730
+ toolId: `assistant.handoff.${agentId}.${item.subjectType}`
2731
+ }));
2732
+ }
2733
+ variantsOf(live) {
2734
+ const all = this.agents.all();
2735
+ const code = all.filter(agent => agent.source === 'code' && agent.shadowOf === live.id);
2736
+ const data = this.dataVariantsOf(live.id);
2737
+ const allowed = this.allowsVariantsOf(live.id, live) && this.agents.isConfigured(live) && isLive(live);
2738
+ return {
2739
+ agentId: live.id,
2740
+ allowsDataVariants: allowed,
2741
+ maxDataVariants: allowed ? this.maxVariants() : 0,
2742
+ variants: [
2743
+ ...code.map(agent => ({ agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null })),
2744
+ ...data.map(agent => {
2745
+ const hasRunSettings = this.agents.isConfigured(agent);
2746
+ return { agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null };
2747
+ })
2748
+ ]
2749
+ };
2750
+ }
2751
+ dataVariantsOf(liveId) {
2752
+ return this.agents.all()
2753
+ .filter(agent => agent.source === 'admin' && agent.shadowOf === liveId)
2754
+ .sort((first, second) => first.id.localeCompare(second.id));
2755
+ }
2756
+ allowsVariantsOf(liveId, live) {
2757
+ const ceiling = this.scenario.shadowVariantsAsData;
2758
+ return ceiling !== undefined
2759
+ && ((ceiling.of ?? []).includes(liveId) || (ceiling.ofAgentsCreatedInAdministration === true && (live?.source ?? 'admin') === 'admin'));
2760
+ }
2761
+ maxVariants() {
2762
+ return this.scenario.shadowVariantsAsData?.maxPerAgent ?? defaultVariants;
2763
+ }
2764
+ }
2765
+ /** A live agent: no shadow variant and not in shadow by its code. */
2766
+ function isLive(agent) {
2767
+ return !agent.shadowOf && !(agent.source === 'code' && agent.shadow?.configured === true);
2768
+ }
2769
+
2338
2770
  /** The bounds of `AllowConsultation`, within which a ceiling may only lower the maxima. */
2339
2771
  const bounds = {
2340
2772
  toolCalls: { min: 0, max: 32, initial: 4 },
@@ -2349,14 +2781,19 @@ const roles = ['reader', 'participant', 'approver'];
2349
2781
  const allRoutes = ['asker-decides', 'subject-agent-rights', 'subject-asker-rights'];
2350
2782
  const classifications = ['public', 'internal', 'confidential', 'restricted'];
2351
2783
  const subjectTypePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2784
+ /** A tool id or a prefix ending in `.*`, as an agent's tool selectors are. */
2785
+ const selectorPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*(?:\.\*)?$/;
2352
2786
  /**
2353
2787
  * Consultations of the mock API's agents: those in code from the scenario, read-only, and those
2354
2788
  * defined as data, saved and removed as on the server within the ceilings in code
2355
2789
  * (`consultationsAsData` for the application, `consultableAsData` for an agent in code). A save
2356
2790
  * checks a consultation as the server does and refuses with the same codes and field errors; every
2357
2791
  * read checks it again, so a consultation that no longer fits, for example after the run settings
2358
- * changed whom the agent answers to, stays with its problems. Data consultations in effect generate
2359
- * their consult and request tools in the tool list and count for the agent's route switches.
2792
+ * changed whom the agent answers to or the agent's own tools no longer cover its tool selection,
2793
+ * stays with its problems. Data consultations in effect generate their consult and request tools in
2794
+ * the tool list and count for the agent's route switches. A new consultation starts off when its
2795
+ * request or the ceiling says so (`startsOff`); one that is off is checked as every read checks it but
2796
+ * offers nothing until `POST .../state` switches it on.
2360
2797
  */
2361
2798
  class NhAssistantMockConsultations {
2362
2799
  constructor(application, agents, rules, now) {
@@ -2365,6 +2802,7 @@ class NhAssistantMockConsultations {
2365
2802
  this.stored = new Map();
2366
2803
  this.codeAllowed = new Map();
2367
2804
  this.codeDescriptions = new Map();
2805
+ this.codeTools = new Map();
2368
2806
  this.agentCeilings = new Map();
2369
2807
  this.applicationCeiling = application ? ceilingOf(application) : null;
2370
2808
  for (const agent of agents) {
@@ -2374,6 +2812,7 @@ class NhAssistantMockConsultations {
2374
2812
  if (agent.consultations !== undefined || agent.consultationDescriptions !== undefined) {
2375
2813
  this.codeAllowed.set(agent.id, agent.consultations ?? {});
2376
2814
  this.codeDescriptions.set(agent.id, agent.consultationDescriptions ?? {});
2815
+ this.codeTools.set(agent.id, agent.consultationTools ?? {});
2377
2816
  }
2378
2817
  if (agent.consultableAsData !== undefined) {
2379
2818
  this.agentCeilings.set(agent.id, agent.consultableAsData);
@@ -2405,7 +2844,8 @@ class NhAssistantMockConsultations {
2405
2844
  maxToolCalls: Math.min(application.maxToolCalls, narrowed.maxToolCalls),
2406
2845
  maxTimeoutSeconds: Math.min(application.maxTimeoutSeconds, narrowed.maxTimeoutSeconds),
2407
2846
  maxAnswerChars: Math.min(application.maxAnswerChars, narrowed.maxAnswerChars),
2408
- maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages)
2847
+ maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages),
2848
+ ...(application.startsOff === true || narrowed.startsOff === true ? { startsOff: true } : {})
2409
2849
  };
2410
2850
  }
2411
2851
  /** The agent's consultations in code, by subject type. */
@@ -2415,6 +2855,7 @@ class NhAssistantMockConsultations {
2415
2855
  return [];
2416
2856
  }
2417
2857
  const descriptions = this.codeDescriptions.get(agent.id) ?? {};
2858
+ const tools = this.codeTools.get(agent.id) ?? {};
2418
2859
  const types = new Set([...Object.values(allowed).flat(), ...Object.keys(descriptions)]);
2419
2860
  return [...types].sort().map(subjectType => {
2420
2861
  const consultToolId = consultToolOf(agent.id, subjectType);
@@ -2426,7 +2867,8 @@ class NhAssistantMockConsultations {
2426
2867
  exposure: ['local', 'agent'],
2427
2868
  consultToolId,
2428
2869
  requestToolId: null,
2429
- askingAgents: this.askersOf(agent.id, consultToolId)
2870
+ askingAgents: this.askersOf(agent.id, consultToolId),
2871
+ toolSelectors: tools[subjectType] ? [...tools[subjectType]] : null
2430
2872
  };
2431
2873
  });
2432
2874
  }
@@ -2474,8 +2916,18 @@ class NhAssistantMockConsultations {
2474
2916
  }
2475
2917
  return tools;
2476
2918
  }
2477
- /** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`. */
2478
- handle(method, agent, subjectType, body, query) {
2919
+ /**
2920
+ * A copy of the consultations defined as data; the returned function puts it back, for an import
2921
+ * of an agent package that fails as a whole and for its preview.
2922
+ */
2923
+ snapshot() {
2924
+ return mockSnapshot(this.stored);
2925
+ }
2926
+ /** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`, and `POST .../state`. */
2927
+ handle(method, agent, subjectType, body, query, action) {
2928
+ if (action === 'state') {
2929
+ return method === 'POST' ? this.setState(agent, subjectType, body) : { status: 405 };
2930
+ }
2479
2931
  if (method === 'DELETE') {
2480
2932
  return this.remove(agent, subjectType, query);
2481
2933
  }
@@ -2483,7 +2935,7 @@ class NhAssistantMockConsultations {
2483
2935
  }
2484
2936
  save(agent, subjectType, body) {
2485
2937
  const request = (body ?? {});
2486
- const reason = text$1(request.reason);
2938
+ const reason = text$2(request.reason);
2487
2939
  if (reason !== null && reason.length > mockMaxStateReason) {
2488
2940
  return mockFailure('assistant-validation', { reason: ['too-long'] });
2489
2941
  }
@@ -2498,12 +2950,16 @@ class NhAssistantMockConsultations {
2498
2950
  return mockFailure('assistant-consultation-in-code');
2499
2951
  }
2500
2952
  // A new consultation starts with the defaults of code, capped by the ceiling.
2501
- const description = text$1(request.description);
2502
- const instructions = text$1(request.instructions);
2503
- const minimumRole = (text$1(request.minimumRole) ?? 'reader');
2504
- const proposalRole = (text$1(request.proposalRole) ?? 'participant');
2953
+ const description = text$2(request.description);
2954
+ const instructions = text$2(request.instructions);
2955
+ const minimumRole = (text$2(request.minimumRole) ?? 'reader');
2956
+ const proposalRole = (text$2(request.proposalRole) ?? 'participant');
2505
2957
  const routes = (request.routes ?? []).map(route => (typeof route === 'string' ? route.trim() : ''));
2506
- const classification = text$1(request.dataClassification);
2958
+ // No selection, or an empty one, offers every tool of the agent.
2959
+ const toolSelectors = Array.isArray(request.toolSelectors) && request.toolSelectors.length > 0
2960
+ ? request.toolSelectors.map(selector => (typeof selector === 'string' ? selector.trim() : ''))
2961
+ : null;
2962
+ const classification = text$2(request.dataClassification);
2507
2963
  const limits = {
2508
2964
  maxToolCalls: request.maxToolCalls ?? Math.min(bounds.toolCalls.initial, ceiling.maxToolCalls),
2509
2965
  timeoutSeconds: request.timeoutSeconds ?? Math.min(bounds.timeoutSeconds.initial, ceiling.maxTimeoutSeconds),
@@ -2535,6 +2991,9 @@ class NhAssistantMockConsultations {
2535
2991
  if (!validRoutes(routes)) {
2536
2992
  add('routes', 'invalid');
2537
2993
  }
2994
+ if (toolSelectors !== null && !validSelectors(toolSelectors)) {
2995
+ add('toolSelectors', 'invalid');
2996
+ }
2538
2997
  if (!within(limits.maxToolCalls, bounds.toolCalls)
2539
2998
  || !within(limits.timeoutSeconds, bounds.timeoutSeconds)
2540
2999
  || !within(limits.maxAnswerChars, bounds.answerChars)
@@ -2544,6 +3003,10 @@ class NhAssistantMockConsultations {
2544
3003
  if (classification !== null && !classifications.includes(classification)) {
2545
3004
  add('dataClassification', 'invalid');
2546
3005
  }
3006
+ // Only a new consultation starts off; one that exists keeps whether it is on.
3007
+ if (request.startsOff === true && request.expectedVersion != null) {
3008
+ add('startsOff', 'invalid');
3009
+ }
2547
3010
  if (Object.keys(errors).length > 0) {
2548
3011
  return mockFailure('assistant-validation', errors);
2549
3012
  }
@@ -2556,14 +3019,16 @@ class NhAssistantMockConsultations {
2556
3019
  description: description,
2557
3020
  instructions,
2558
3021
  minimumRole,
2559
- askerPolicy: text$1(request.askerPolicy),
3022
+ askerPolicy: text$2(request.askerPolicy),
2560
3023
  routes,
2561
3024
  proposalRole,
2562
- proposalPolicy: text$1(request.proposalPolicy),
3025
+ proposalPolicy: text$2(request.proposalPolicy),
3026
+ toolSelectors,
2563
3027
  ...limits,
2564
3028
  allowRequests: request.allowRequests === true,
2565
3029
  exposeToMcp: request.exposeToMcp === true,
2566
3030
  dataClassification: classification,
3031
+ off: existing ? existing.off : request.startsOff === true || ceiling.startsOff === true,
2567
3032
  createdAt: existing?.createdAt ?? now,
2568
3033
  createdBy: existing?.createdBy ?? 'current-user',
2569
3034
  changedAt: now,
@@ -2584,6 +3049,31 @@ class NhAssistantMockConsultations {
2584
3049
  this.stored.set(key$2(agent.id, subjectType), row);
2585
3050
  return { status: 200, body: this.toConsultation(agent, row) };
2586
3051
  }
3052
+ /** `POST admin/agents/{id}/consultations/{subjectType}/state`: on or off, without a new version. */
3053
+ setState(agent, subjectType, body) {
3054
+ const request = body;
3055
+ if (request?.state !== 'on' && request?.state !== 'off') {
3056
+ return mockFailure('assistant-validation', { state: [request?.state === undefined ? 'required' : 'invalid'] });
3057
+ }
3058
+ if (typeof request.reason === 'string' && request.reason.trim().length > mockMaxStateReason) {
3059
+ return mockFailure('assistant-validation', { reason: ['too-long'] });
3060
+ }
3061
+ if (this.inCode(agent.id, subjectType)) {
3062
+ return mockFailure('assistant-consultation-in-code');
3063
+ }
3064
+ const existing = this.stored.get(key$2(agent.id, subjectType));
3065
+ if (!existing) {
3066
+ return mockFailure('assistant-consultation-not-found');
3067
+ }
3068
+ const off = request.state === 'off';
3069
+ if (existing.off !== off) {
3070
+ existing.off = off;
3071
+ existing.changedAt = this.now();
3072
+ existing.changedBy = 'current-user';
3073
+ existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
3074
+ }
3075
+ return { status: 200, body: this.toConsultation(agent, existing) };
3076
+ }
2587
3077
  remove(agent, subjectType, query) {
2588
3078
  const reason = query.get('reason');
2589
3079
  if (reason !== null && reason.trim().length > mockMaxStateReason) {
@@ -2610,7 +3100,7 @@ class NhAssistantMockConsultations {
2610
3100
  * no longer fit and with limits capped at the ceiling, each with a problem.
2611
3101
  */
2612
3102
  check(agent, row) {
2613
- const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false });
3103
+ const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false, toolSelectors: [] });
2614
3104
  const ceiling = this.ceilingOf(agent);
2615
3105
  if (!ceiling) {
2616
3106
  return out('not-consultable');
@@ -2676,7 +3166,19 @@ class NhAssistantMockConsultations {
2676
3166
  || row.maxHistoryMessages > ceiling.maxHistoryMessages) {
2677
3167
  problems.push('limit-above-ceiling');
2678
3168
  }
2679
- return { inEffect: true, problems, routes: kept, allowRequests };
3169
+ // The selection only narrows the agent's own tools; one they no longer cover drops, and a
3170
+ // selection with none left offers no tools, never every tool of the agent.
3171
+ let toolSelectors = null;
3172
+ if (row.toolSelectors !== null) {
3173
+ toolSelectors = row.toolSelectors.filter(selector => agent.toolSelectors.some(own => selectorCovers(own, selector)));
3174
+ if (toolSelectors.length < row.toolSelectors.length) {
3175
+ problems.push('tools-not-allowed');
3176
+ }
3177
+ }
3178
+ // Off, it is checked as every read checks it, and offers nothing until it is switched on.
3179
+ return row.off
3180
+ ? { inEffect: false, problems, routes: [], allowRequests: false, toolSelectors: [] }
3181
+ : { inEffect: true, problems, routes: kept, allowRequests, toolSelectors };
2680
3182
  }
2681
3183
  namesOnlyTheInitiator(policy) {
2682
3184
  const summary = this.rules.approvalPolicySummaries().find(item => item.name === policy);
@@ -2689,6 +3191,8 @@ class NhAssistantMockConsultations {
2689
3191
  return {
2690
3192
  ...stored,
2691
3193
  routes: [...row.routes],
3194
+ toolSelectors: row.toolSelectors ? [...row.toolSelectors] : null,
3195
+ toolSelectorsInEffect: check.toolSelectors ? [...check.toolSelectors] : null,
2692
3196
  instructionsHash: row.instructions === null ? null : mockHashText(row.instructions),
2693
3197
  inEffect: check.inEffect,
2694
3198
  problem: check.problems[0] ?? null,
@@ -2728,7 +3232,9 @@ function ceilingOf(ceiling) {
2728
3232
  maxAnswerChars: ceiling.maxAnswerChars ?? bounds.answerChars.max,
2729
3233
  maxHistoryMessages: ceiling.maxHistoryMessages ?? bounds.history.max,
2730
3234
  maxConsultations,
2731
- maxInstructionsLength: maxInstructions$1
3235
+ maxInstructionsLength: maxInstructions$1,
3236
+ // Reported only when the application lets new consultations start off, as older servers omit it.
3237
+ ...(ceiling.startsOff === true ? { startsOff: true } : {})
2732
3238
  };
2733
3239
  }
2734
3240
  /** The field error of each problem a consultation would have, as the server maps them on save. */
@@ -2750,7 +3256,8 @@ function fieldFailure(problems) {
2750
3256
  'no-approver': ['proposalPolicy', 'invalid'],
2751
3257
  'limit-above-ceiling': ['limits', 'not-allowed'],
2752
3258
  'requests-not-allowed': ['allowRequests', 'not-allowed'],
2753
- 'mcp-not-allowed': ['exposeToMcp', 'not-allowed']
3259
+ 'mcp-not-allowed': ['exposeToMcp', 'not-allowed'],
3260
+ 'tools-not-allowed': ['toolSelectors', 'not-allowed']
2754
3261
  };
2755
3262
  const errors = {};
2756
3263
  for (const problem of problems) {
@@ -2765,6 +3272,15 @@ function validRoutes(routes) {
2765
3272
  && new Set(routes).size === routes.length
2766
3273
  && !(routes.includes('subject-agent-rights') && routes.includes('subject-asker-rights'));
2767
3274
  }
3275
+ /** Valid selectors, each once. */
3276
+ function validSelectors(selectors) {
3277
+ return selectors.every(selector => selector.length <= 128 && selectorPattern.test(selector))
3278
+ && new Set(selectors).size === selectors.length;
3279
+ }
3280
+ /** The agent's selector covers a consultation's selector: the same tool, or a prefix that includes it. */
3281
+ function selectorCovers(agentSelector, selector) {
3282
+ return agentSelector === selector || (agentSelector.endsWith('.*') && selector.startsWith(agentSelector.slice(0, -1)));
3283
+ }
2768
3284
  function within(value, range) {
2769
3285
  return Number.isInteger(value) && value >= range.min && value <= range.max;
2770
3286
  }
@@ -2782,11 +3298,116 @@ function covers$1(selector, toolId) {
2782
3298
  function key$2(agentId, subjectType) {
2783
3299
  return `${agentId}/${subjectType}`;
2784
3300
  }
2785
- function text$1(value) {
3301
+ function text$2(value) {
2786
3302
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
2787
3303
  }
2788
3304
 
2789
- const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3305
+ /**
3306
+ * The application's text assets of the mock API, as its `INhAiTextAssetSource` would supply them:
3307
+ * `GET admin/instruction-assets` lists them without content and `GET admin/instruction-assets/{id}/versions/{version}`
3308
+ * reads one. A skill version or an agent created in the administration that names one is checked as
3309
+ * the server checks it: the asset must exist with the hash the request names. `setAsset` and
3310
+ * `removeAsset` change the assets afterwards, so a reference that no longer resolves shows its problem
3311
+ * (`asset-changed`, `asset-missing`), as after the server's next load. Without `instructionAssets` in
3312
+ * the scenario the application offers none and the run catalog says so.
3313
+ */
3314
+ class NhAssistantMockInstructionAssets {
3315
+ constructor(assets) {
3316
+ this.assets = new Map();
3317
+ this.supported = assets !== undefined;
3318
+ for (const asset of assets ?? []) {
3319
+ this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
3320
+ }
3321
+ }
3322
+ /** Publishes or changes one version of an asset. */
3323
+ setAsset(asset) {
3324
+ this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
3325
+ }
3326
+ /** Removes one version of an asset. */
3327
+ removeAsset(assetId, version) {
3328
+ this.assets.delete(keyOf$1(assetId, version));
3329
+ }
3330
+ /** `admin/instruction-assets[/{id}/versions/{version}]`; `segments` start after `instruction-assets`. */
3331
+ handle(method, segments) {
3332
+ if (method !== 'GET') {
3333
+ return { status: 405 };
3334
+ }
3335
+ if (segments.length === 0) {
3336
+ const list = {
3337
+ supported: this.supported,
3338
+ assets: [...this.assets.values()].map(asset => ({
3339
+ assetId: asset.assetId,
3340
+ version: asset.version,
3341
+ hash: mockHashText(asset.content),
3342
+ replacedByAssetId: asset.replacedByAssetId ?? null,
3343
+ deprecatedAt: asset.deprecatedAt ?? null
3344
+ }))
3345
+ };
3346
+ return { status: 200, body: list };
3347
+ }
3348
+ const [assetId, versions, version] = segments;
3349
+ const asset = segments.length === 3 && versions === 'versions' ? this.assets.get(keyOf$1(assetId, Number(version))) : undefined;
3350
+ if (!this.supported || !asset) {
3351
+ return mockFailure('assistant-instructions-asset-not-found');
3352
+ }
3353
+ const text = {
3354
+ assetId: asset.assetId,
3355
+ version: asset.version,
3356
+ hash: mockHashText(asset.content),
3357
+ content: asset.content,
3358
+ problem: null
3359
+ };
3360
+ return { status: 200, body: text };
3361
+ }
3362
+ /**
3363
+ * Adds the field errors of a reference a save names, as the server does: `instructionsAsset`
3364
+ * `not-allowed` without a source, `invalid` for a malformed reference, `not-found` for a missing
3365
+ * asset, and `instructionsAsset.hash` `invalid` for another hash.
3366
+ */
3367
+ check(reference, errors) {
3368
+ if (!this.supported) {
3369
+ errors['instructionsAsset'] = ['not-allowed'];
3370
+ return;
3371
+ }
3372
+ if (typeof reference.assetId !== 'string' || !mockDashCase.test(reference.assetId) || !Number.isInteger(reference.version) || reference.version < 1) {
3373
+ errors['instructionsAsset'] = ['invalid'];
3374
+ return;
3375
+ }
3376
+ const problem = this.problemOf(reference);
3377
+ if (problem === 'asset-missing') {
3378
+ errors['instructionsAsset'] = ['not-found'];
3379
+ }
3380
+ else if (problem === 'asset-changed') {
3381
+ errors['instructionsAsset.hash'] = ['invalid'];
3382
+ }
3383
+ else if (problem !== null) {
3384
+ errors['instructionsAsset'] = ['invalid'];
3385
+ }
3386
+ }
3387
+ /** Why the reference does not resolve to its hash now, or `null` when it does. */
3388
+ problemOf(reference) {
3389
+ if (!this.supported) {
3390
+ return 'no-asset-source';
3391
+ }
3392
+ const asset = this.assets.get(keyOf$1(reference.assetId, reference.version));
3393
+ if (!asset) {
3394
+ return 'asset-missing';
3395
+ }
3396
+ if (asset.content.trim().length === 0 || asset.content.length > mockMaxInstructions) {
3397
+ return 'asset-invalid';
3398
+ }
3399
+ return mockHashText(asset.content) === reference.hash ? null : 'asset-changed';
3400
+ }
3401
+ /** The text the reference resolves to, or `null` when it does not resolve. */
3402
+ textOf(reference) {
3403
+ return this.problemOf(reference) === null ? this.assets.get(keyOf$1(reference.assetId, reference.version)).content : null;
3404
+ }
3405
+ }
3406
+ function keyOf$1(assetId, version) {
3407
+ return `${assetId}@${version}`;
3408
+ }
3409
+
3410
+ const dashCase$5 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
2790
3411
  const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
2791
3412
  const accountabilities = ['agent', 'subject-responsible', 'service-account'];
2792
3413
  const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
@@ -2811,17 +3432,22 @@ const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'max
2811
3432
  * checks the names against the scenario's run catalog as the server checks them against the code;
2812
3433
  * new skills and triggers start in shadow. A schedule without a subject set runs for the whole
2813
3434
  * application and fits only an agent that works on it; a stored trigger that no longer fits what its
2814
- * agent works on stays, out of effect, with the problem `not-worked-on`. The mock runs no agents, so
2815
- * a comparison counts nothing.
3435
+ * agent works on stays, out of effect, with the problem `not-worked-on`. Triggers have append-only
3436
+ * versions as skills do: a change applies at once or, with `apply` `shadow`, becomes the candidate next
3437
+ * to the live version, which the administrator promotes or discards. The mock runs no agents, so a
3438
+ * comparison counts nothing.
2816
3439
  */
2817
3440
  class NhAssistantMockRunConfiguration {
2818
- constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario)) {
3441
+ constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario), instructionAssets = new NhAssistantMockInstructionAssets(scenario.instructionAssets)) {
2819
3442
  this.agents = agents;
2820
3443
  this.now = now;
2821
3444
  this.accounts = accounts;
3445
+ this.instructionAssets = instructionAssets;
2822
3446
  this.settings = new Map();
2823
3447
  this.skills = new Map();
2824
3448
  this.triggers = new Map();
3449
+ /** Every version of every data trigger by trigger id, live and candidate included, oldest first. */
3450
+ this.triggerVersions = new Map();
2825
3451
  const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
2826
3452
  this.consultations = new NhAssistantMockConsultations(scenario.consultationsAsData, scenario.agents ?? [], {
2827
3453
  find: agentId => this.agents.find(agentId),
@@ -2854,7 +3480,8 @@ class NhAssistantMockRunConfiguration {
2854
3480
  divisions: scenario.runCatalog?.divisions ?? false,
2855
3481
  // The platform's default file limits, unless the scenario's application chose its own.
2856
3482
  fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
2857
- consultationCeiling: this.consultations.applicationCeiling
3483
+ consultationCeiling: this.consultations.applicationCeiling,
3484
+ instructionAssets: instructionAssets.supported
2858
3485
  };
2859
3486
  this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
2860
3487
  ...trigger,
@@ -2878,6 +3505,18 @@ class NhAssistantMockRunConfiguration {
2878
3505
  get profileNames() {
2879
3506
  return [...this.catalog.profiles];
2880
3507
  }
3508
+ /**
3509
+ * A copy of the run settings, skills, triggers and consultations; the returned function puts it
3510
+ * back, for an import of an agent package that fails as a whole and for its preview.
3511
+ */
3512
+ snapshot() {
3513
+ const restoreOwn = mockSnapshot(this.settings, this.skills, this.triggers, this.triggerVersions);
3514
+ const restoreConsultations = this.consultations.snapshot();
3515
+ return () => {
3516
+ restoreOwn();
3517
+ restoreConsultations();
3518
+ };
3519
+ }
2881
3520
  /**
2882
3521
  * The agent's consultations defined as data in effect with their routes in effect, which count for
2883
3522
  * its route switches.
@@ -2895,8 +3534,8 @@ class NhAssistantMockRunConfiguration {
2895
3534
  }
2896
3535
  /**
2897
3536
  * `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]`,
2898
- * `triggers/{triggerId}[/shadow]` and `consultations/{subjectType}`; `segments` start after the
2899
- * agent id.
3537
+ * `triggers/{triggerId}[/shadow|/versions|/candidate/...]` and `consultations/{subjectType}[/state]`;
3538
+ * `segments` start after the agent id.
2900
3539
  */
2901
3540
  handle(method, agentId, segments, body, query) {
2902
3541
  const agent = this.agents.find(agentId);
@@ -2919,14 +3558,22 @@ class NhAssistantMockRunConfiguration {
2919
3558
  }
2920
3559
  return segments.length === 2 ? this.skill(method, agent, id, body, query) : null;
2921
3560
  }
3561
+ if (area === 'triggers' && id !== undefined && (segments.length === 3 && action === 'versions' || segments.length === 4 && action === 'candidate')) {
3562
+ if (!agent) {
3563
+ return mockFailure('assistant-agent-not-found');
3564
+ }
3565
+ return action === 'versions'
3566
+ ? method === 'GET' ? { status: 200, body: this.triggerHistory(agent.id, id) } : { status: 405 }
3567
+ : this.triggerCandidate(method, agent, id, child, body);
3568
+ }
2922
3569
  if (area === 'triggers' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'shadow'))) {
2923
3570
  if (!agent) {
2924
3571
  return mockFailure('assistant-agent-not-found');
2925
3572
  }
2926
3573
  return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
2927
3574
  }
2928
- if (area === 'consultations' && id !== undefined && segments.length === 2) {
2929
- return agent ? this.consultations.handle(method, agent, id, body, query) : mockFailure('assistant-agent-not-found');
3575
+ if (area === 'consultations' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'state'))) {
3576
+ return agent ? this.consultations.handle(method, agent, id, body, query, action) : mockFailure('assistant-agent-not-found');
2930
3577
  }
2931
3578
  return null;
2932
3579
  }
@@ -2937,7 +3584,9 @@ class NhAssistantMockRunConfiguration {
2937
3584
  .filter(trigger => this.ownerOf(trigger) === agent.id)
2938
3585
  .map(trigger => {
2939
3586
  const problem = this.triggerProblem(agent, trigger);
2940
- return problem ? { ...withoutOwner(trigger), inEffect: false, problem } : withoutOwner(trigger);
3587
+ const live = problem ? { ...withoutOwner(trigger), inEffect: false, problem } : withoutOwner(trigger);
3588
+ const candidate = this.candidateOf(trigger.triggerId);
3589
+ return { ...live, candidate: candidate ? this.candidateDto(agent, candidate) : null };
2941
3590
  });
2942
3591
  return {
2943
3592
  agentId: agent.id,
@@ -2949,9 +3598,16 @@ class NhAssistantMockRunConfiguration {
2949
3598
  codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
2950
3599
  dataTriggers,
2951
3600
  problems: [
3601
+ ...dataSkills
3602
+ .flatMap(skill => [{ kind: 'skill', skill }, ...(skill.candidate ? [{ kind: 'candidate', skill: skill.candidate }] : [])])
3603
+ .filter(entry => entry.skill.instructionsAsset && entry.skill.problem !== null)
3604
+ .map(entry => ({ agentId: agent.id, kind: entry.kind, id: entry.skill.skillId, code: entry.skill.problem })),
2952
3605
  ...dataTriggers
2953
3606
  .filter(trigger => trigger.problem !== null)
2954
3607
  .map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
3608
+ ...dataTriggers
3609
+ .filter(trigger => trigger.candidate && trigger.candidate.problem !== null)
3610
+ .map(trigger => ({ agentId: agent.id, kind: 'trigger-candidate', id: trigger.triggerId, code: trigger.candidate.problem })),
2955
3611
  ...this.consultations.problems(agent)
2956
3612
  ],
2957
3613
  source: agent.source,
@@ -3008,7 +3664,8 @@ class NhAssistantMockRunConfiguration {
3008
3664
  }
3009
3665
  if (this.liveSkills(agent.id).length > 0
3010
3666
  || [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)
3011
- || this.consultations.has(agent.id)) {
3667
+ || this.consultations.has(agent.id)
3668
+ || this.agents.acceptsDataHandOffs?.(agent.id)) {
3012
3669
  return mockFailure('assistant-run-settings-in-use');
3013
3670
  }
3014
3671
  this.settings.delete(agent.id);
@@ -3018,7 +3675,7 @@ class NhAssistantMockRunConfiguration {
3018
3675
  return { status: 405 };
3019
3676
  }
3020
3677
  const request = (body ?? {});
3021
- const serviceAccount = text(request.serviceAccountActorId);
3678
+ const serviceAccount = text$1(request.serviceAccountActorId);
3022
3679
  const errors = {};
3023
3680
  if (!accountabilities.includes(request.accountability)) {
3024
3681
  errors['accountability'] = [request.accountability ? 'invalid' : 'required'];
@@ -3061,18 +3718,18 @@ class NhAssistantMockRunConfiguration {
3061
3718
  delegateFromInitiator: request.delegateFromInitiator ?? false,
3062
3719
  allowGatewayReads: request.allowGatewayReads ?? false,
3063
3720
  elevatedReads: request.elevatedReads ?? 'restricted',
3064
- approvalPolicy: text(request.approvalPolicy),
3721
+ approvalPolicy: text$1(request.approvalPolicy),
3065
3722
  retryCount: request.retryCount ?? 3,
3066
3723
  dailyToolCalls: request.dailyToolCalls ?? null,
3067
3724
  dailyTokens: request.dailyTokens ?? null,
3068
3725
  maxConcurrentRuns: request.maxConcurrentRuns ?? 4,
3069
3726
  maxRunsPerSubjectPerDay: request.maxRunsPerSubjectPerDay ?? 10,
3070
3727
  maxTokensPerRun: request.maxTokensPerRun ?? null,
3071
- triageProfile: text(request.triageProfile),
3072
- ruleHandler: text(request.ruleHandler),
3728
+ triageProfile: text$1(request.triageProfile),
3729
+ ruleHandler: text$1(request.ruleHandler),
3073
3730
  changedAt: this.now(),
3074
3731
  changedBy: 'current-user',
3075
- reason: text(request.reason),
3732
+ reason: text$1(request.reason),
3076
3733
  inEffect: true,
3077
3734
  problem: null,
3078
3735
  acceptsFiles: request.acceptsFiles ?? false,
@@ -3110,9 +3767,9 @@ class NhAssistantMockRunConfiguration {
3110
3767
  return { status: 405 };
3111
3768
  }
3112
3769
  const request = (body ?? {});
3113
- const description = text(request.description);
3770
+ const description = text$1(request.description);
3114
3771
  const errors = {};
3115
- if (!dashCase$4.test(skillId)) {
3772
+ if (!dashCase$5.test(skillId)) {
3116
3773
  errors['skillId'] = ['invalid'];
3117
3774
  }
3118
3775
  if (description === null) {
@@ -3124,6 +3781,14 @@ class NhAssistantMockRunConfiguration {
3124
3781
  if ((request.instructions ?? '').length > maxInstructions) {
3125
3782
  errors['instructions'] = ['too-long'];
3126
3783
  }
3784
+ // A version takes its instructions from an asset of the application or has text of its own.
3785
+ const instructionsAsset = request.instructionsAsset ?? null;
3786
+ if (instructionsAsset !== null) {
3787
+ if (text$1(request.instructions) !== null) {
3788
+ errors['instructions'] = ['not-allowed'];
3789
+ }
3790
+ this.instructionAssets.check(instructionsAsset, errors);
3791
+ }
3127
3792
  if ((request.tools ?? []).some(tool => !agent.toolSelectors.some(selector => covers(selector, tool)))) {
3128
3793
  errors['tools'] = ['invalid'];
3129
3794
  }
@@ -3140,7 +3805,7 @@ class NhAssistantMockRunConfiguration {
3140
3805
  if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
3141
3806
  errors['startsOff'] = ['invalid'];
3142
3807
  }
3143
- const classification = text(request.dataClassification);
3808
+ const classification = text$1(request.dataClassification);
3144
3809
  if (classification !== null && !dataClassifications.includes(classification)) {
3145
3810
  errors['dataClassification'] = ['invalid'];
3146
3811
  }
@@ -3163,27 +3828,30 @@ class NhAssistantMockRunConfiguration {
3163
3828
  version.status = 'superseded';
3164
3829
  }
3165
3830
  }
3166
- const instructions = text(request.instructions);
3831
+ const instructions = instructionsAsset === null ? text$1(request.instructions) : null;
3167
3832
  const saved = {
3168
3833
  skillId,
3169
3834
  version: versions.reduce((latest, version) => Math.max(latest, version.version), 0) + 1,
3170
3835
  status: candidate ? 'candidate' : 'live',
3171
3836
  description: description,
3172
3837
  instructions,
3173
- instructionsHash: instructions === null ? null : hashText(instructions),
3838
+ instructionsHash: instructionsAsset?.hash ?? (instructions === null ? null : hashText(instructions)),
3839
+ instructionsAsset: instructionsAsset === null
3840
+ ? null
3841
+ : { assetId: instructionsAsset.assetId, version: instructionsAsset.version, hash: instructionsAsset.hash },
3174
3842
  tools: [...new Set(request.tools ?? [])],
3175
- profile: text(request.profile),
3843
+ profile: text$1(request.profile),
3176
3844
  dataClassification: classification,
3177
3845
  acceptsFiles: request.acceptsFiles ?? false,
3178
3846
  ...storedFileLimits(request),
3179
- ruleHandler: text(request.ruleHandler),
3847
+ ruleHandler: text$1(request.ruleHandler),
3180
3848
  maxTokensPerRun: request.maxTokensPerRun ?? null,
3181
3849
  dailyToolCalls: request.dailyToolCalls ?? null,
3182
3850
  dailyTokens: request.dailyTokens ?? null,
3183
- approvalPolicy: text(request.approvalPolicy),
3851
+ approvalPolicy: text$1(request.approvalPolicy),
3184
3852
  changedAt: this.now(),
3185
3853
  changedBy: 'current-user',
3186
- reason: text(request.reason),
3854
+ reason: text$1(request.reason),
3187
3855
  inEffect: true,
3188
3856
  problem: null
3189
3857
  };
@@ -3248,7 +3916,8 @@ class NhAssistantMockRunConfiguration {
3248
3916
  instructionsHash: version.instructionsHash,
3249
3917
  createdAt: version.changedAt,
3250
3918
  createdBy: version.changedBy,
3251
- reason: version.reason
3919
+ reason: version.reason,
3920
+ instructionsAsset: version.instructionsAsset ?? null
3252
3921
  }));
3253
3922
  }
3254
3923
  trigger(method, agent, triggerId, body, query) {
@@ -3269,6 +3938,10 @@ class NhAssistantMockRunConfiguration {
3269
3938
  if (!versionMatches(query, existing.version)) {
3270
3939
  return mockFailure('assistant-version-conflict');
3271
3940
  }
3941
+ // Out of effect; its versions stay as history.
3942
+ for (const version of this.triggerVersions.get(triggerId) ?? []) {
3943
+ version.status = version.status === 'live' ? 'superseded' : version.status === 'candidate' ? 'discarded' : version.status;
3944
+ }
3272
3945
  this.triggers.delete(triggerId);
3273
3946
  return { status: 204 };
3274
3947
  }
@@ -3278,7 +3951,7 @@ class NhAssistantMockRunConfiguration {
3278
3951
  const request = (body ?? {});
3279
3952
  const schedule = request.source === 'schedule';
3280
3953
  const errors = {};
3281
- if (!dashCase$4.test(triggerId)) {
3954
+ if (!dashCase$5.test(triggerId)) {
3282
3955
  errors['triggerId'] = ['invalid'];
3283
3956
  }
3284
3957
  if (request.source !== 'event' && request.source !== 'schedule') {
@@ -3293,16 +3966,16 @@ class NhAssistantMockRunConfiguration {
3293
3966
  if (request.source === 'event') {
3294
3967
  this.requireKnown(errors, 'event', request.event, this.catalog.triggerEvents.map(item => item.name), true);
3295
3968
  // An event that names its object type up front fits only an agent that works on it.
3296
- const subjectType = this.catalog.triggerEvents.find(item => item.name === text(request.event))?.subjectType;
3969
+ const subjectType = this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.subjectType;
3297
3970
  if (!errors['event'] && subjectType && !mockWorksOnCovers(agent, subjectType)) {
3298
3971
  errors['event'] = ['invalid'];
3299
3972
  }
3300
3973
  }
3301
3974
  if (schedule) {
3302
- if ((text(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
3303
- errors['cron'] = [text(request.cron) === null ? 'required' : 'invalid'];
3975
+ if ((text$1(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
3976
+ errors['cron'] = [text$1(request.cron) === null ? 'required' : 'invalid'];
3304
3977
  }
3305
- if (text(request.subjectSet) !== null) {
3978
+ if (text$1(request.subjectSet) !== null) {
3306
3979
  this.requireKnown(errors, 'subjectSet', request.subjectSet, this.catalog.subjectSets.map(item => item.name));
3307
3980
  }
3308
3981
  else if (!mockWorksOnCovers(agent, mockSystemSubjectType)) {
@@ -3310,7 +3983,7 @@ class NhAssistantMockRunConfiguration {
3310
3983
  // that works on it may do.
3311
3984
  errors['subjectSet'] = ['required'];
3312
3985
  }
3313
- const message = text(request.message);
3986
+ const message = text$1(request.message);
3314
3987
  if (message === null) {
3315
3988
  errors['message'] = ['required'];
3316
3989
  }
@@ -3318,7 +3991,7 @@ class NhAssistantMockRunConfiguration {
3318
3991
  errors['message'] = ['too-long'];
3319
3992
  }
3320
3993
  }
3321
- const skill = text(request.skill);
3994
+ const skill = text$1(request.skill);
3322
3995
  if (skill !== null && !this.skillIds(agent).includes(skill)) {
3323
3996
  errors['skill'] = ['not-found'];
3324
3997
  }
@@ -3327,11 +4000,16 @@ class NhAssistantMockRunConfiguration {
3327
4000
  if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
3328
4001
  errors['filters'] = ['invalid'];
3329
4002
  }
4003
+ // A new trigger has no live version to evaluate a candidate next to.
4004
+ if ((request.apply !== undefined && request.apply !== 'live' && request.apply !== 'shadow')
4005
+ || (request.apply === 'shadow' && (request.expectedVersion ?? null) === null)) {
4006
+ errors['apply'] = ['invalid'];
4007
+ }
3330
4008
  if (Object.keys(errors).length > 0) {
3331
4009
  return mockFailure('assistant-validation', errors);
3332
4010
  }
3333
4011
  if (request.source === 'event') {
3334
- const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
4012
+ const offered = (this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.filters ?? []).map(filter => filter.name);
3335
4013
  if (filters.some(name => !offered.includes(name))) {
3336
4014
  return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
3337
4015
  }
@@ -3339,35 +4017,140 @@ class NhAssistantMockRunConfiguration {
3339
4017
  if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
3340
4018
  return mockFailure('assistant-version-conflict');
3341
4019
  }
4020
+ const candidate = request.apply === 'shadow';
4021
+ const history = this.triggerVersions.get(triggerId) ?? [];
3342
4022
  const saved = {
3343
4023
  triggerId,
3344
4024
  skillId: skill,
3345
4025
  source: request.source,
3346
- event: schedule ? null : text(request.event),
3347
- cron: schedule ? text(request.cron) : null,
3348
- timeZone: schedule ? text(request.timeZone) : null,
3349
- subjectSet: schedule ? text(request.subjectSet) : null,
3350
- message: schedule ? text(request.message) : null,
4026
+ event: schedule ? null : text$1(request.event),
4027
+ cron: schedule ? text$1(request.cron) : null,
4028
+ timeZone: schedule ? text$1(request.timeZone) : null,
4029
+ subjectSet: schedule ? text$1(request.subjectSet) : null,
4030
+ message: schedule ? text$1(request.message) : null,
3351
4031
  conflict: request.conflict ?? (schedule ? 'ignore' : 'coalesce'),
3352
4032
  debounceSeconds: schedule ? 0 : request.debounceSeconds ?? 120,
3353
- shadow: existing?.shadow ?? true,
3354
- version: (existing?.version ?? 0) + 1,
4033
+ // A candidate always fires in shadow; a new version keeps the trigger's mode.
4034
+ shadow: candidate || (existing?.shadow ?? true),
4035
+ version: history.reduce((latest, version) => Math.max(latest, version.version), existing?.version ?? 0) + 1,
3355
4036
  changedAt: this.now(),
3356
4037
  changedBy: 'current-user',
3357
- reason: text(request.reason),
4038
+ reason: text$1(request.reason),
3358
4039
  inEffect: true,
3359
4040
  problem: null,
3360
- filters: schedule ? [] : filters
4041
+ filters: schedule ? [] : filters,
4042
+ createdAt: this.now(),
4043
+ createdBy: 'current-user'
3361
4044
  };
4045
+ for (const version of history) {
4046
+ if (version.status === 'candidate') {
4047
+ version.status = 'discarded';
4048
+ }
4049
+ else if (version.status === 'live' && !candidate) {
4050
+ version.status = 'superseded';
4051
+ }
4052
+ }
4053
+ if (existing && history.length === 0) {
4054
+ // A trigger of the scenario's preset starts its history with its live version.
4055
+ history.push({ ...withoutOwner(existing), status: candidate ? 'live' : 'superseded', agentId: agent.id });
4056
+ }
4057
+ this.triggerVersions.set(triggerId, [...history, { ...withoutOwner(saved), status: candidate ? 'candidate' : 'live', agentId: agent.id }]);
4058
+ if (candidate) {
4059
+ return { status: 200, body: this.candidateDto(agent, this.candidateOf(triggerId)) };
4060
+ }
3362
4061
  this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
3363
4062
  return { status: 200, body: withoutOwner(saved) };
3364
4063
  }
3365
- triggerShadow(method, agent, triggerId, body) {
3366
- if (method !== 'POST') {
3367
- return { status: 405 };
3368
- }
3369
- const existing = this.triggers.get(triggerId);
3370
- if (!existing || this.ownerOf(existing) !== agent.id) {
4064
+ /** `admin/agents/{id}/triggers/{triggerId}/candidate/{promote|discard|comparison}`. */
4065
+ triggerCandidate(method, agent, triggerId, action, body) {
4066
+ const live = this.triggers.get(triggerId);
4067
+ const candidate = live && this.ownerOf(live) === agent.id ? this.candidateOf(triggerId) : undefined;
4068
+ if (action === 'comparison' && method === 'GET') {
4069
+ if (!candidate || !live) {
4070
+ return mockFailure('assistant-trigger-candidate-not-found');
4071
+ }
4072
+ const comparison = {
4073
+ agentId: agent.id,
4074
+ triggerId,
4075
+ from: candidate.createdAt ?? candidate.changedAt,
4076
+ to: this.now(),
4077
+ live: emptyStats(live.version),
4078
+ candidate: emptyStats(candidate.version),
4079
+ pairedEvents: 0,
4080
+ liveOnlyEvents: 0,
4081
+ candidateOnlyEvents: 0,
4082
+ tools: []
4083
+ };
4084
+ return { status: 200, body: comparison };
4085
+ }
4086
+ if ((action !== 'promote' && action !== 'discard') || method !== 'POST') {
4087
+ return { status: 405 };
4088
+ }
4089
+ const request = body;
4090
+ if (typeof request?.version !== 'number') {
4091
+ return mockFailure('assistant-validation', { version: ['required'] });
4092
+ }
4093
+ if (!candidate || !live) {
4094
+ return mockFailure('assistant-trigger-candidate-not-found');
4095
+ }
4096
+ if (candidate.version !== request.version) {
4097
+ return mockFailure('assistant-version-conflict');
4098
+ }
4099
+ if (action === 'discard') {
4100
+ candidate.status = 'discarded';
4101
+ return { status: 204 };
4102
+ }
4103
+ for (const version of this.triggerVersions.get(triggerId) ?? []) {
4104
+ if (version.status === 'live') {
4105
+ version.status = 'superseded';
4106
+ }
4107
+ }
4108
+ candidate.status = 'live';
4109
+ // The promoted version keeps working in the trigger's mode.
4110
+ const { status, agentId, ...definition } = candidate;
4111
+ const promoted = { ...definition, shadow: live.shadow, changedAt: this.now(), changedBy: 'current-user' };
4112
+ candidate.shadow = live.shadow;
4113
+ this.triggers.set(triggerId, Object.assign(promoted, { [ownerKey]: agent.id }));
4114
+ return { status: 200, body: withoutOwner(promoted) };
4115
+ }
4116
+ /** Every version of a data trigger of the agent, newest first, with its definition. */
4117
+ triggerHistory(agentId, triggerId) {
4118
+ return [...(this.triggerVersions.get(triggerId) ?? [])]
4119
+ .filter(version => version.agentId === agentId)
4120
+ .sort((left, right) => right.version - left.version)
4121
+ .map(version => ({
4122
+ version: version.version,
4123
+ status: version.status,
4124
+ skillId: version.skillId,
4125
+ source: version.source,
4126
+ event: version.event,
4127
+ filters: [...(version.filters ?? [])],
4128
+ cron: version.cron,
4129
+ timeZone: version.timeZone,
4130
+ subjectSet: version.subjectSet,
4131
+ message: version.message,
4132
+ conflict: version.conflict,
4133
+ debounceSeconds: version.debounceSeconds,
4134
+ createdAt: version.createdAt ?? version.changedAt,
4135
+ createdBy: version.createdBy ?? version.changedBy,
4136
+ reason: version.reason
4137
+ }));
4138
+ }
4139
+ candidateOf(triggerId) {
4140
+ return (this.triggerVersions.get(triggerId) ?? []).find(version => version.status === 'candidate');
4141
+ }
4142
+ /** A candidate as the server shows it: always in shadow, with its own problem. */
4143
+ candidateDto(agent, candidate) {
4144
+ const { status, agentId, ...definition } = candidate;
4145
+ const problem = this.triggerProblem(agent, definition);
4146
+ return { ...definition, shadow: true, inEffect: problem === null, problem };
4147
+ }
4148
+ triggerShadow(method, agent, triggerId, body) {
4149
+ if (method !== 'POST') {
4150
+ return { status: 405 };
4151
+ }
4152
+ const existing = this.triggers.get(triggerId);
4153
+ if (!existing || this.ownerOf(existing) !== agent.id) {
3371
4154
  return mockFailure('assistant-trigger-not-found');
3372
4155
  }
3373
4156
  const request = body;
@@ -3413,8 +4196,11 @@ class NhAssistantMockRunConfiguration {
3413
4196
  * that its profile, or else its agent's, does not permit.
3414
4197
  */
3415
4198
  withWarnings(agent, skill) {
4199
+ // A version whose asset no longer resolves to its hash is out of effect, as after the server's load.
4200
+ const assetProblem = skill.instructionsAsset ? this.instructionAssets.problemOf(skill.instructionsAsset) : null;
3416
4201
  return {
3417
4202
  ...skill,
4203
+ ...(assetProblem !== null ? { inEffect: false, problem: assetProblem } : {}),
3418
4204
  warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
3419
4205
  };
3420
4206
  }
@@ -3422,7 +4208,7 @@ class NhAssistantMockRunConfiguration {
3422
4208
  return trigger[ownerKey];
3423
4209
  }
3424
4210
  requireKnown(errors, field, value, known, required = false) {
3425
- const name = text(value);
4211
+ const name = text$1(value);
3426
4212
  if (name === null) {
3427
4213
  if (required) {
3428
4214
  errors[field] = ['required'];
@@ -3470,7 +4256,7 @@ function withoutOwner(trigger) {
3470
4256
  function emptyStats(version) {
3471
4257
  return { version, runs: 0, completed: 0, failed: 0, proposals: 0, toolCalls: 0, tokens: 0 };
3472
4258
  }
3473
- function text(value) {
4259
+ function text$1(value) {
3474
4260
  return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
3475
4261
  }
3476
4262
  function inRange(value, min, max) {
@@ -3527,20 +4313,29 @@ class NhAssistantMockAdmin {
3527
4313
  this.canAdminister = admin.canAdminister ?? true;
3528
4314
  this.preferences = { ...defaultPreferences, ...scenario.preferences };
3529
4315
  this.contextVersions.push({ version: 1, hash: mockHashText(admin.context ?? ''), updatedAt: now(), updatedBy: null, text: admin.context ?? '' });
4316
+ this.instructionAssets = new NhAssistantMockInstructionAssets(admin.instructionAssets);
3530
4317
  this.agents = new NhAssistantMockAdminAgents(scenario, now, {
3531
4318
  hasServer: serverId => this.mcp.hasServer(serverId),
3532
4319
  agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
3533
4320
  divisionsListed: () => this.runConfiguration.divisionsListed,
3534
4321
  profileNames: () => this.runConfiguration.profileNames,
3535
- dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
4322
+ dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId),
4323
+ shadowVariantRefusal: input => this.handOffs.shadowVariantRefusal(input),
4324
+ instructionAssets: () => this.instructionAssets
3536
4325
  });
3537
4326
  this.accounts = new NhAssistantMockAccounts(admin);
3538
4327
  this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
3539
4328
  find: agentId => this.agents.find(agentId),
3540
4329
  all: () => this.agents.all(),
3541
4330
  knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
3542
- setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
3543
- }, now, this.accounts);
4331
+ setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff),
4332
+ acceptsDataHandOffs: agentId => this.handOffs.acceptsDataHandOffs(agentId)
4333
+ }, now, this.accounts, this.instructionAssets);
4334
+ this.handOffs = new NhAssistantMockHandOffs(admin, {
4335
+ find: agentId => this.agents.find(agentId),
4336
+ all: () => this.agents.all(),
4337
+ isConfigured: agent => this.runConfiguration.isConfigured(agent)
4338
+ }, now);
3544
4339
  this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
3545
4340
  agents: () => this.agents.all(),
3546
4341
  chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
@@ -3593,6 +4388,30 @@ class NhAssistantMockAdmin {
3593
4388
  allAgents() {
3594
4389
  return this.agents.all();
3595
4390
  }
4391
+ /** One agent of the administration, or `undefined`. */
4392
+ findAgent(agentId) {
4393
+ return this.agents.find(agentId);
4394
+ }
4395
+ /** The definition in code of an agent in code; `null` for an agent created here. */
4396
+ codeDefinition(agentId) {
4397
+ return this.agents.codeDefinition(agentId);
4398
+ }
4399
+ /**
4400
+ * A copy of the agents and their run configuration, consultations and hand-offs; the returned
4401
+ * function puts it back, for an import of an agent package that fails as a whole and for its preview.
4402
+ */
4403
+ snapshot() {
4404
+ const restores = [this.agents.snapshot(), this.runConfiguration.snapshot(), this.handOffs.snapshot()];
4405
+ return () => {
4406
+ for (const restore of restores) {
4407
+ restore();
4408
+ }
4409
+ };
4410
+ }
4411
+ /** Makes the next version of an agent, as a change of its metadata does. */
4412
+ nextAgentVersion(agentId) {
4413
+ return this.agents.nextVersion(agentId);
4414
+ }
3596
4415
  handlePreferences(method, body) {
3597
4416
  if (method === 'GET') {
3598
4417
  return { status: 200, body: this.preferences };
@@ -3635,6 +4454,8 @@ class NhAssistantMockAdmin {
3635
4454
  return method === 'GET' && segments.length === 2 ? { status: 200, body: this.toolCatalog() } : mockFailure('assistant-not-found');
3636
4455
  case 'run-catalog':
3637
4456
  return segments.length === 2 ? this.runConfiguration.runCatalog(method) : mockFailure('assistant-not-found');
4457
+ case 'instruction-assets':
4458
+ return this.instructionAssets.handle(method, segments.slice(2));
3638
4459
  case 'accounts':
3639
4460
  return segments.length === 2 ? this.accounts.search(method, query) : mockFailure('assistant-not-found');
3640
4461
  case 'agents':
@@ -3647,6 +4468,12 @@ class NhAssistantMockAdmin {
3647
4468
  if (id !== undefined && (action === 'replay' || action === 'replays') && segments.length === 4) {
3648
4469
  return this.agents.agentReplay(method, id, action, body);
3649
4470
  }
4471
+ if (id !== undefined && (action === 'hand-offs' || action === 'shadow-variants')) {
4472
+ const handed = this.handOffs.handle(method, id, segments.slice(3), body, query);
4473
+ if (handed) {
4474
+ return handed;
4475
+ }
4476
+ }
3650
4477
  if (id !== undefined && action !== undefined) {
3651
4478
  const configured = this.runConfiguration.handle(method, id, segments.slice(3), body, query);
3652
4479
  if (configured) {
@@ -3755,6 +4582,1329 @@ function toContext(version) {
3755
4582
  return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
3756
4583
  }
3757
4584
 
4585
+ /** The longest package id. */
4586
+ const maxPackageId = 128;
4587
+ /** The longest package description. */
4588
+ const maxPackageDescription = 500;
4589
+ /** The header of the export that lists the references to agents outside the selection. */
4590
+ const warningsHeader = 'X-NewHeap-Package-Warnings';
4591
+ /** The order in which an import writes the parts of its agents. */
4592
+ const phaseOrder = ['agent', 'runSettings', 'skill', 'trigger', 'consultation', 'handOff', 'metadata'];
4593
+ /** The properties of the package format each lockable field stands for. */
4594
+ const lockProperties = {
4595
+ agent: {
4596
+ instructions: ['instructions', 'instructionsAsset'],
4597
+ worksOn: ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy']
4598
+ },
4599
+ runSettings: {
4600
+ dailyBudget: ['dailyToolCalls', 'dailyTokens'],
4601
+ files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
4602
+ },
4603
+ skill: {
4604
+ instructions: ['instructions', 'instructionsAsset'],
4605
+ dailyBudget: ['dailyToolCalls', 'dailyTokens'],
4606
+ files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
4607
+ },
4608
+ trigger: {},
4609
+ consultation: {
4610
+ limits: ['maxToolCalls', 'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages']
4611
+ },
4612
+ handOff: {},
4613
+ metadata: {}
4614
+ };
4615
+ /** The fields of the definition an administrator overrides on an agent in code, and its overlay carries. */
4616
+ const codeAgentFields = ['displayName', 'description', 'instructions', 'toolSelectors', 'requiredPolicy', 'autonomy'];
4617
+ /** The properties the strict format reads per object, required ones first. */
4618
+ const formatShapes = {
4619
+ root: { required: ['format', 'formatVersion', 'package', 'agents'], optional: [] },
4620
+ package: { required: ['id', 'version'], optional: ['description'] },
4621
+ agent: {
4622
+ required: ['id', 'source'],
4623
+ optional: ['definition', 'runSettings', 'skills', 'triggers', 'consultations', 'handOffs', 'metadata', 'groups', 'locks', 'hashes']
4624
+ },
4625
+ definition: {
4626
+ required: [],
4627
+ optional: [
4628
+ 'displayName', 'description', 'instructions', 'instructionsAsset', 'toolSelectors', 'requiredPolicy', 'autonomy', 'offeredInChat',
4629
+ 'worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy', 'profile',
4630
+ 'dataClassification', 'startsOff', 'startsInShadow', 'shadowOf'
4631
+ ]
4632
+ },
4633
+ runSettings: {
4634
+ required: ['accountability'],
4635
+ optional: [
4636
+ 'delegateFromInitiator', 'allowGatewayReads', 'elevatedReads', 'approvalPolicy', 'retryCount', 'dailyToolCalls', 'dailyTokens',
4637
+ 'maxConcurrentRuns', 'maxRunsPerSubjectPerDay', 'maxTokensPerRun', 'triageProfile', 'ruleHandler', 'acceptsFiles', 'maxFileBytes',
4638
+ 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'
4639
+ ]
4640
+ },
4641
+ skill: {
4642
+ required: ['id', 'description'],
4643
+ optional: [
4644
+ 'instructions', 'instructionsAsset', 'tools', 'profile', 'ruleHandler', 'maxTokensPerRun', 'dailyToolCalls', 'dailyTokens',
4645
+ 'approvalPolicy', 'dataClassification', 'acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn',
4646
+ 'startsOff'
4647
+ ]
4648
+ },
4649
+ trigger: {
4650
+ required: ['id', 'source'],
4651
+ optional: ['skill', 'event', 'filters', 'cron', 'timeZone', 'subjectSet', 'message', 'conflict', 'debounceSeconds']
4652
+ },
4653
+ consultation: {
4654
+ required: ['subjectType', 'description'],
4655
+ optional: [
4656
+ 'instructions', 'minimumRole', 'askerPolicy', 'routes', 'proposalRole', 'proposalPolicy', 'toolSelectors', 'maxToolCalls',
4657
+ 'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages', 'allowRequests', 'exposeToMcp', 'dataClassification', 'startsOff'
4658
+ ]
4659
+ },
4660
+ handOff: { required: ['subjectType', 'sources'], optional: ['description', 'notifySource', 'maxPerSubjectPerDay'] },
4661
+ instructionsAsset: { required: ['assetId', 'version', 'hash'], optional: [] }
4662
+ };
4663
+ /**
4664
+ * Agent packages of the mock API (S7-2), with the rules of the server: an export of exactly the chosen
4665
+ * agents with the references to agents outside them, a preview per part (`created`, `changed`,
4666
+ * `unchanged`, `kept-local`, `left-behind`, `refused`) after one check of the whole package against
4667
+ * the host as it would be after the import, and an import that applies every part through the mock's
4668
+ * own endpoints in dependency order, or nothing: a refused part answers `409 assistant-package-refused`
4669
+ * with the plain field error `parts: invalid`, and the preview says why. Linked parts carry their link
4670
+ * in every read; every save path refuses a change of a locked field with `{field}: locked`, and removing
4671
+ * a locked part or deleting or resetting a linked agent answers `409 assistant-package-locked`.
4672
+ * Unlinking makes a part local: the next upgrade keeps it as `kept-local` unless the import relinks it.
4673
+ * An import takes over an agent the host has with a package agent's id and no package of its own when
4674
+ * `relink` names that whole agent; otherwise the agent refuses the package (`assistant-agent-exists`).
4675
+ * Hashes are the SHA-256 of each part's canonical JSON; a hash a package gives is not checked.
4676
+ */
4677
+ class NhAssistantMockAgentPackages {
4678
+ constructor(context) {
4679
+ this.context = context;
4680
+ this.links = new Map();
4681
+ this.installed = new Map();
4682
+ }
4683
+ /**
4684
+ * Imports the scenario's packages in order, as an administrator would; a package the import refuses
4685
+ * fails the scenario at once.
4686
+ */
4687
+ importPresets(presets) {
4688
+ for (const preset of presets) {
4689
+ const result = this.importPackage({ package: preset.package, options: { serviceAccounts: preset.serviceAccounts ?? null } }, true);
4690
+ if (result.status !== 200) {
4691
+ throw new Error(`The mock refused the scenario's agent package '${preset.package?.package?.id}': ${JSON.stringify(result.body ?? null)}.`);
4692
+ }
4693
+ }
4694
+ }
4695
+ /**
4696
+ * `POST admin/agent-packages/export`, `.../preview` and `.../import`, and `POST
4697
+ * admin/agents/{id}/package/unlink`; `null` for another route.
4698
+ */
4699
+ handle(method, segments, body) {
4700
+ if (segments[0] !== 'admin') {
4701
+ return null;
4702
+ }
4703
+ if (segments[1] === 'agent-packages' && segments.length === 3) {
4704
+ if (method !== 'POST') {
4705
+ return { status: 405 };
4706
+ }
4707
+ switch (segments[2]) {
4708
+ case 'export':
4709
+ return this.exportPackage(body);
4710
+ case 'preview':
4711
+ return this.importPackage(body, false);
4712
+ case 'import':
4713
+ return this.importPackage(body, true);
4714
+ default:
4715
+ return mockFailure('assistant-not-found');
4716
+ }
4717
+ }
4718
+ if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
4719
+ return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
4720
+ }
4721
+ return null;
4722
+ }
4723
+ /**
4724
+ * Runs a save of the administration under the package locks: a save may keep a locked field's value
4725
+ * or take the package's, never another; such a change is undone and refused with `{field}: locked`.
4726
+ * Removing a part with locks and deleting a linked agent answer `409 assistant-package-locked`, as
4727
+ * does a reset that would give a locked field of the definition another value; removing a linked
4728
+ * part without locks drops its link. Other requests run as they are.
4729
+ */
4730
+ guard(method, segments, run) {
4731
+ const target = guardedPart(method, segments);
4732
+ if (!target) {
4733
+ return run();
4734
+ }
4735
+ if (target.part === null) {
4736
+ return this.isLinked(target.agentId) ? mockFailure('assistant-package-locked') : run();
4737
+ }
4738
+ const reset = target.reset === true;
4739
+ const link = this.links.get(target.agentId)?.parts.get(target.part);
4740
+ if (!link) {
4741
+ return run();
4742
+ }
4743
+ if (target.removal) {
4744
+ if (link.lockedFields.length > 0) {
4745
+ return mockFailure('assistant-package-locked');
4746
+ }
4747
+ const removed = run();
4748
+ if (removed.status < 300) {
4749
+ this.links.get(target.agentId)?.parts.delete(target.part);
4750
+ }
4751
+ return removed;
4752
+ }
4753
+ if (link.lockedFields.length === 0) {
4754
+ return run();
4755
+ }
4756
+ const kind = kindOf(target.part);
4757
+ const before = this.lockView(target.agentId, target.part);
4758
+ const restore = this.context.snapshot();
4759
+ const result = run();
4760
+ if (result.status >= 300) {
4761
+ return result;
4762
+ }
4763
+ const changed = lockedChanges(kind, link.lockedFields, before, this.lockView(target.agentId, target.part), link.view);
4764
+ if (changed.length > 0) {
4765
+ restore();
4766
+ return reset ? mockFailure('assistant-package-locked') : mockFailure('assistant-validation', Object.fromEntries(changed.map(field => [field, ['locked']])));
4767
+ }
4768
+ return result;
4769
+ }
4770
+ /**
4771
+ * Adds the package links to an administration answer: the agent's package on agents, the part's link
4772
+ * on run settings, skills, triggers, consultations, hand-offs and metadata entries.
4773
+ */
4774
+ decorate(method, segments, result) {
4775
+ if (segments[0] !== 'admin' || segments[1] !== 'agents' || result.status >= 300 || result.body === undefined || this.links.size === 0) {
4776
+ return result;
4777
+ }
4778
+ const [, , agentId, area, id, action] = segments;
4779
+ const body = result.body;
4780
+ if (agentId === undefined) {
4781
+ if (Array.isArray(body)) {
4782
+ return { ...result, body: body.map(agent => this.withAgentPackage(agent)) };
4783
+ }
4784
+ return { ...result, body: this.withAgentPackage(body) };
4785
+ }
4786
+ if (area === undefined || (area === 'reset' && segments.length === 4)) {
4787
+ return { ...result, body: this.withAgentPackage(body) };
4788
+ }
4789
+ if (method === 'DELETE' && area !== 'metadata') {
4790
+ return result;
4791
+ }
4792
+ switch (area) {
4793
+ case 'configuration': {
4794
+ const configuration = body;
4795
+ return {
4796
+ ...result,
4797
+ body: {
4798
+ ...configuration,
4799
+ runSettings: configuration.runSettings ? this.withLink(agentId, 'runSettings', configuration.runSettings) : configuration.runSettings,
4800
+ dataSkills: configuration.dataSkills.map(skill => this.withLink(agentId, `skill:${skill.skillId}`, skill)),
4801
+ dataTriggers: configuration.dataTriggers.map(trigger => this.withLink(agentId, `trigger:${trigger.triggerId}`, trigger)),
4802
+ dataConsultations: configuration.dataConsultations?.map(item => this.withLink(agentId, `consultation:${item.subjectType}`, item))
4803
+ }
4804
+ };
4805
+ }
4806
+ case 'run-settings':
4807
+ return { ...result, body: this.withLink(agentId, 'runSettings', body) };
4808
+ case 'skills':
4809
+ return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote'))
4810
+ ? { ...result, body: this.withLink(agentId, `skill:${id}`, body) }
4811
+ : result;
4812
+ case 'triggers':
4813
+ return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote') || action === 'shadow')
4814
+ ? { ...result, body: this.withLink(agentId, `trigger:${id}`, body) }
4815
+ : result;
4816
+ case 'consultations':
4817
+ return id !== undefined ? { ...result, body: this.withLink(agentId, `consultation:${id}`, body) } : result;
4818
+ case 'hand-offs': {
4819
+ if (id !== undefined) {
4820
+ return { ...result, body: this.withLink(agentId, `handOff:${id}`, body) };
4821
+ }
4822
+ const handOffs = body;
4823
+ return { ...result, body: { ...handOffs, dataHandOffs: handOffs.dataHandOffs.map(item => this.withLink(agentId, `handOff:${item.subjectType}`, item)) } };
4824
+ }
4825
+ case 'metadata':
4826
+ if (id !== undefined) {
4827
+ return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
4828
+ }
4829
+ return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
4830
+ default:
4831
+ return result;
4832
+ }
4833
+ }
4834
+ /** A copy of the links and the imported packages; the returned function puts them back. */
4835
+ snapshot() {
4836
+ return mockSnapshot(this.links, this.installed);
4837
+ }
4838
+ /** Forgets the links of an agent that was deleted, as the server removes them with it. */
4839
+ forget(agentId) {
4840
+ this.links.delete(agentId);
4841
+ }
4842
+ exportPackage(body) {
4843
+ const request = (body ?? {});
4844
+ const agentIds = request.agentIds;
4845
+ if (!Array.isArray(agentIds) || agentIds.length === 0 || !agentIds.every(id => typeof id === 'string')) {
4846
+ return mockFailure('assistant-validation', { agentIds: ['required'] });
4847
+ }
4848
+ if (agentIds.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
4849
+ return mockFailure('assistant-validation', { agentIds: ['too-long'] });
4850
+ }
4851
+ if (new Set(agentIds).size !== agentIds.length) {
4852
+ return mockFailure('assistant-validation', { agentIds: ['invalid'] });
4853
+ }
4854
+ const reads = [];
4855
+ for (const agentId of agentIds) {
4856
+ const read = this.read(agentId);
4857
+ if (!read) {
4858
+ return mockFailure('assistant-agent-not-found');
4859
+ }
4860
+ reads.push(read);
4861
+ }
4862
+ const options = request.options ?? {};
4863
+ const errors = {};
4864
+ const linked = this.links.get(reads[0].agent.id);
4865
+ const packageId = text(options.packageId) ?? linked?.packageId ?? reads[0].agent.id;
4866
+ if (!mockDashCase.test(packageId) || packageId.length > maxPackageId) {
4867
+ errors['packageId'] = ['invalid'];
4868
+ }
4869
+ const installed = this.installed.get(packageId);
4870
+ const version = options.version ?? (installed ? installed.version + 1 : 1);
4871
+ if (!Number.isInteger(version) || version < 1) {
4872
+ errors['version'] = ['invalid'];
4873
+ }
4874
+ const description = text(options.description);
4875
+ if (description !== null && description.length > maxPackageDescription) {
4876
+ errors['description'] = ['too-long'];
4877
+ }
4878
+ const agents = reads.map(read => this.packageAgentOf(read));
4879
+ for (const [agentId, locks] of Object.entries(options.locks ?? {})) {
4880
+ const agent = agents.find(item => item.id === agentId);
4881
+ for (const [part, fields] of Object.entries(locks ?? {})) {
4882
+ // A lock of an agent or part the export does not hold, or of a field its kind does not lock.
4883
+ const problem = agent ? lockProblem(agent, part, fields) : 'not-found';
4884
+ if (problem) {
4885
+ errors['locks'] = ['invalid'];
4886
+ }
4887
+ else if (agent && fields.length > 0) {
4888
+ agent.locks = { ...(agent.locks ?? {}), [part]: [...fields] };
4889
+ }
4890
+ }
4891
+ }
4892
+ if (Object.keys(errors).length > 0) {
4893
+ return mockFailure('assistant-validation', errors);
4894
+ }
4895
+ for (const agent of agents) {
4896
+ agent.hashes = Object.fromEntries([...partsOf(agent)].map(([part, value]) => [part, mockSha256(canonicalJson(value))]));
4897
+ }
4898
+ const agentPackage = {
4899
+ format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
4900
+ formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
4901
+ package: { id: packageId, version, ...(description !== null ? { description } : {}) },
4902
+ agents
4903
+ };
4904
+ return {
4905
+ status: 200,
4906
+ text: JSON.stringify(withoutNulls(agentPackage), null, 2),
4907
+ headers: {
4908
+ 'content-type': 'application/json',
4909
+ 'content-disposition': `attachment; filename="agent-package-${packageId}.json"`,
4910
+ [warningsHeader]: JSON.stringify(this.warningsOf(reads))
4911
+ }
4912
+ };
4913
+ }
4914
+ /** Every reference of the exported agents to agents outside the selection. */
4915
+ warningsOf(reads) {
4916
+ const selected = new Set(reads.map(read => read.agent.id));
4917
+ const everyone = this.agents();
4918
+ const warnings = [];
4919
+ const outside = (agentId, part, code, referencedAgentId) => {
4920
+ if (!selected.has(referencedAgentId)) {
4921
+ warnings.push({ agentId, part, code, referencedAgentId });
4922
+ }
4923
+ };
4924
+ for (const { agent, configuration, handOffs } of reads) {
4925
+ if (agent.shadowOf) {
4926
+ outside(agent.id, 'agent', 'shadow-of', agent.shadowOf);
4927
+ }
4928
+ for (const variant of everyone.filter(item => item.shadowOf === agent.id)) {
4929
+ outside(agent.id, 'agent', 'shadow-variant', variant.id);
4930
+ }
4931
+ for (const handOff of handOffs?.dataHandOffs ?? []) {
4932
+ for (const source of handOff.sources) {
4933
+ outside(agent.id, `handOff:${handOff.subjectType}`, 'hand-off-source', source);
4934
+ }
4935
+ }
4936
+ for (const consultation of configuration?.dataConsultations ?? []) {
4937
+ for (const asker of consultation.askingAgents) {
4938
+ outside(agent.id, `consultation:${consultation.subjectType}`, 'consultation-asker', asker);
4939
+ }
4940
+ }
4941
+ }
4942
+ return warnings;
4943
+ }
4944
+ /**
4945
+ * `POST admin/agent-packages/preview` (`keep` false) and `.../import`: the package read strictly, its
4946
+ * parts applied in dependency order on a copy of the host, and, for an import without a refused part,
4947
+ * applied for real with the links of its parts. An import with a refused part answers
4948
+ * `assistant-package-refused` with `parts: invalid`, as the server does; the preview names each reason.
4949
+ */
4950
+ importPackage(body, keep) {
4951
+ const request = (body ?? {});
4952
+ const read = readPackage(request.package);
4953
+ if ('status' in read) {
4954
+ return read;
4955
+ }
4956
+ const agentPackage = read.package;
4957
+ const options = request.options ?? {};
4958
+ const apply = options.apply ?? 'live';
4959
+ if (apply !== 'live' && apply !== 'shadow') {
4960
+ return mockFailure('assistant-validation', { apply: ['invalid'] });
4961
+ }
4962
+ // The accounts and the parts to relink name agents of the package, as the server checks them.
4963
+ const ids = new Set(agentPackage.agents.map(agent => agent.id));
4964
+ const optionErrors = {};
4965
+ if (Object.entries(options.serviceAccounts ?? {}).some(([agentId, actorId]) => !ids.has(agentId) || typeof actorId !== 'string' || actorId.trim().length === 0 || actorId.trim().length > 256)) {
4966
+ optionErrors['serviceAccounts'] = ['invalid'];
4967
+ }
4968
+ if ((options.relink ?? []).some(item => !item || !ids.has(item.agentId)
4969
+ || (item.part !== null && item.part !== undefined && (typeof item.part !== 'string' || !validPartKey(item.part))))) {
4970
+ optionErrors['relink'] = ['invalid'];
4971
+ }
4972
+ if (Object.keys(optionErrors).length > 0) {
4973
+ return mockFailure('assistant-validation', optionErrors);
4974
+ }
4975
+ // The check tries every part on a copy of the host, in live mode, and puts the host back.
4976
+ const restoreHost = this.context.snapshot();
4977
+ const restoreOwn = this.snapshot();
4978
+ const planned = this.apply(agentPackage, options, 'live', null);
4979
+ restoreHost();
4980
+ restoreOwn();
4981
+ const preview = {
4982
+ packageId: agentPackage.package.id,
4983
+ packageVersion: agentPackage.package.version,
4984
+ installedVersion: this.installed.get(agentPackage.package.id)?.version ?? null,
4985
+ importable: planned.every(part => part.outcome !== 'refused'),
4986
+ parts: planned.map(publicPart)
4987
+ };
4988
+ if (!keep) {
4989
+ return { status: 200, body: preview };
4990
+ }
4991
+ if (!preview.importable) {
4992
+ return mockFailure('assistant-package-refused', refusedErrors);
4993
+ }
4994
+ // The import applies the package for real, with the chosen mode, and links its parts.
4995
+ const restoreAll = [this.context.snapshot(), this.snapshot()];
4996
+ const applied = this.apply(agentPackage, options, apply, planned);
4997
+ if (applied.some(part => part.outcome === 'refused')) {
4998
+ for (const restore of restoreAll) {
4999
+ restore();
5000
+ }
5001
+ return mockFailure('assistant-package-refused', refusedErrors);
5002
+ }
5003
+ this.link(agentPackage, options, planned);
5004
+ return {
5005
+ status: 200,
5006
+ body: {
5007
+ packageId: agentPackage.package.id,
5008
+ packageVersion: agentPackage.package.version,
5009
+ hash: mockSha256(canonicalJson(agentPackage)),
5010
+ parts: applied.map(publicPart)
5011
+ }
5012
+ };
5013
+ }
5014
+ /**
5015
+ * Applies every agent and part of the package to the host in dependency order: agents that are no
5016
+ * shadow variant of another agent of the package first, each phase for all of them, then the
5017
+ * variants; reports per part what happened, `refused` with the endpoint's code or field errors.
5018
+ * Parts an administrator unlinked stay as they are (`kept-local`) unless `relink` names them; agents
5019
+ * and parts an earlier version of the package linked that it no longer holds are `left-behind`.
5020
+ */
5021
+ apply(agentPackage, options, mode, plan) {
5022
+ const packageId = agentPackage.package.id;
5023
+ const ids = new Set(agentPackage.agents.map(agent => agent.id));
5024
+ const live = agentPackage.agents.filter(agent => !agent.definition?.shadowOf || !ids.has(agent.definition.shadowOf));
5025
+ const variants = agentPackage.agents.filter(agent => !live.includes(agent));
5026
+ const results = new Map();
5027
+ const refusedAgents = new Set();
5028
+ for (const group of [live, variants]) {
5029
+ for (const kind of phaseOrder) {
5030
+ for (const agent of group) {
5031
+ if (refusedAgents.has(agent.id)) {
5032
+ continue;
5033
+ }
5034
+ for (const [part, value] of partsOf(agent)) {
5035
+ if (kindOf(part) !== kind) {
5036
+ continue;
5037
+ }
5038
+ const planned = plan?.find(item => item.agentId === agent.id && item.part === part);
5039
+ // The import writes only what the check found new or changed.
5040
+ const outcome = planned && planned.outcome !== 'created' && planned.outcome !== 'changed'
5041
+ ? planned
5042
+ : this.applyPart(agentPackage, agent, part, value, options, mode);
5043
+ results.set(agent.id, [...(results.get(agent.id) ?? []), outcome]);
5044
+ if (kind === 'agent' && outcome.outcome === 'refused') {
5045
+ refusedAgents.add(agent.id);
5046
+ }
5047
+ }
5048
+ // Groups go with the agent: they are no part, and stay free once imported.
5049
+ if (kind === 'agent' && !refusedAgents.has(agent.id) && agent.groups && !agent.definition?.shadowOf) {
5050
+ const groups = this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}/groups`, { groups: agent.groups });
5051
+ if (groups.status >= 300) {
5052
+ const refusal = refusalOf(agent.id, 'agent', groups, agent.locks?.['agent'] ?? []);
5053
+ results.set(agent.id, [refusal, ...(results.get(agent.id) ?? []).filter(item => item.part !== 'agent')]);
5054
+ refusedAgents.add(agent.id);
5055
+ }
5056
+ }
5057
+ }
5058
+ }
5059
+ }
5060
+ // What an earlier version of the package linked and this one no longer holds stays, unlinked.
5061
+ const ordered = agentPackage.agents.flatMap(agent => results.get(agent.id) ?? []);
5062
+ for (const [agentId, link] of this.links) {
5063
+ if (link.packageId !== packageId) {
5064
+ continue;
5065
+ }
5066
+ if (!ids.has(agentId)) {
5067
+ ordered.push(leftBehind(agentId, 'agent'));
5068
+ continue;
5069
+ }
5070
+ const held = new Set(partsOf(agentPackage.agents.find(agent => agent.id === agentId)).keys());
5071
+ for (const part of link.parts.keys()) {
5072
+ if (!held.has(part)) {
5073
+ ordered.push(leftBehind(agentId, part));
5074
+ }
5075
+ }
5076
+ }
5077
+ return ordered;
5078
+ }
5079
+ /** Applies one part through its endpoint and reports what happened to it. */
5080
+ applyPart(agentPackage, agent, part, value, options, mode) {
5081
+ const lockedFields = agent.locks?.[part] ?? [];
5082
+ const link = this.links.get(agent.id);
5083
+ const linkedHere = link?.packageId === agentPackage.package.id;
5084
+ const relinked = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined || item.part === part));
5085
+ const lockProblemCode = lockProblem(agent, part, lockedFields);
5086
+ if (lockProblemCode) {
5087
+ return { ...base(agent.id, part, lockedFields), outcome: 'refused', errors: { locks: [lockProblemCode] } };
5088
+ }
5089
+ const host = this.read(agent.id);
5090
+ if (part === 'agent') {
5091
+ // A relink of the whole agent lets the import take over an agent the host has with its id.
5092
+ const takenOver = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined));
5093
+ const otherPackage = !!link && !linkedHere && link.parts.size > 0;
5094
+ const refusal = agentRefusal(agent, host?.agent ?? null, linkedHere || takenOver, otherPackage);
5095
+ if (refusal) {
5096
+ return { ...base(agent.id, part, lockedFields), outcome: 'refused', code: refusal };
5097
+ }
5098
+ }
5099
+ if (linkedHere && !relinked && (link.local.has(part) || link.local.has(NH_ASSISTANT_PACKAGE_LOCK_ALL))) {
5100
+ return { ...base(agent.id, part, lockedFields), outcome: 'kept-local' };
5101
+ }
5102
+ const before = host ? this.hostHash(host, part) : null;
5103
+ const partLink = linkedHere ? link.parts.get(part) : undefined;
5104
+ const result = this.write(agent, part, value, host, options, mode);
5105
+ if (result.status >= 300) {
5106
+ return refusalOf(agent.id, part, result, lockedFields);
5107
+ }
5108
+ const after = this.read(agent.id);
5109
+ const hashAfter = after ? this.hostHash(after, part) : null;
5110
+ // In shadow a changed skill or trigger is saved as the candidate next to its live version.
5111
+ const kind = kindOf(part);
5112
+ const candidateVersion = mode === 'shadow' && before !== null && (kind === 'skill' || kind === 'trigger')
5113
+ ? result.body?.version ?? null
5114
+ : null;
5115
+ let outcome = 'changed';
5116
+ if (before === null) {
5117
+ outcome = 'created';
5118
+ }
5119
+ else if (candidateVersion === null && before === hashAfter) {
5120
+ outcome = 'unchanged';
5121
+ }
5122
+ return {
5123
+ ...base(agent.id, part, lockedFields),
5124
+ outcome,
5125
+ changedLocally: !!partLink && before !== partLink.hash,
5126
+ candidateVersion,
5127
+ hashAfter,
5128
+ viewAfter: this.lockView(agent.id, part)
5129
+ };
5130
+ }
5131
+ /** Writes one part through the endpoint a save of it uses. */
5132
+ write(agent, part, value, host, options, mode) {
5133
+ const kind = kindOf(part);
5134
+ const key = keyOf(part);
5135
+ const agentPath = `admin/agents/${encodeURIComponent(agent.id)}`;
5136
+ const item = value;
5137
+ switch (kind) {
5138
+ case 'agent':
5139
+ return this.writeDefinition(agent, host);
5140
+ case 'runSettings': {
5141
+ const settings = item;
5142
+ const account = settings.accountability === 'service-account' ? options.serviceAccounts?.[agent.id] ?? null : null;
5143
+ return this.context.call('PUT', `${agentPath}/run-settings`, {
5144
+ ...settings,
5145
+ serviceAccountActorId: account,
5146
+ expectedVersion: host?.configuration?.runSettings?.version ?? null
5147
+ });
5148
+ }
5149
+ case 'skill': {
5150
+ const { id: _, startsOff, ...skill } = item;
5151
+ const current = host?.configuration?.dataSkills.find(existing => existing.skillId === key);
5152
+ return this.context.call('PUT', `${agentPath}/skills/${encodeURIComponent(key)}`, {
5153
+ ...skill,
5154
+ tools: skill['tools'] ?? [],
5155
+ expectedVersion: current?.version ?? null,
5156
+ ...(current ? { apply: mode } : startsOff === true ? { startsOff: true } : {})
5157
+ });
5158
+ }
5159
+ case 'trigger': {
5160
+ const { id: _, ...trigger } = item;
5161
+ const current = host?.configuration?.dataTriggers.find(existing => existing.triggerId === key);
5162
+ return this.context.call('PUT', `${agentPath}/triggers/${encodeURIComponent(key)}`, {
5163
+ ...trigger,
5164
+ expectedVersion: current?.version ?? null,
5165
+ ...(current ? { apply: mode } : {})
5166
+ });
5167
+ }
5168
+ case 'consultation': {
5169
+ const { subjectType: _, startsOff, ...consultation } = item;
5170
+ const current = host?.configuration?.dataConsultations?.find(existing => existing.subjectType === key);
5171
+ return this.context.call('PUT', `${agentPath}/consultations/${encodeURIComponent(key)}`, {
5172
+ ...consultation,
5173
+ routes: consultation['routes'] ?? [],
5174
+ expectedVersion: current?.version ?? null,
5175
+ ...(!current && startsOff === true ? { startsOff: true } : {})
5176
+ });
5177
+ }
5178
+ case 'handOff': {
5179
+ const { subjectType: _, ...handOff } = item;
5180
+ const current = host?.handOffs?.dataHandOffs.find(existing => existing.subjectType === key);
5181
+ return this.context.call('PUT', `${agentPath}/hand-offs/${encodeURIComponent(key)}`, {
5182
+ ...handOff,
5183
+ expectedVersion: current?.version ?? null
5184
+ });
5185
+ }
5186
+ case 'metadata': {
5187
+ const entry = this.read(agent.id)?.metadata.find(existing => existing.name === key);
5188
+ return entry
5189
+ ? this.context.call('PUT', `${agentPath}/metadata/${encodeURIComponent(key)}`, { value, expectedVersion: entry.version })
5190
+ : mockFailure('assistant-metadata-entry-not-found');
5191
+ }
5192
+ }
5193
+ }
5194
+ /**
5195
+ * Writes the definition: creates an agent created here when its id is free, or makes its next
5196
+ * version; overrides an agent in code with the overlay, or restores its code without one.
5197
+ */
5198
+ writeDefinition(agent, host) {
5199
+ const definition = agent.definition ?? {};
5200
+ if (agent.source === 'code') {
5201
+ const code = this.context.codeDefinition(agent.id);
5202
+ const overlay = codeAgentFields.filter(field => definition[field] !== undefined && definition[field] !== null);
5203
+ if (overlay.length === 0) {
5204
+ return host?.agent.isOverridden
5205
+ ? this.context.call('POST', `admin/agents/${encodeURIComponent(agent.id)}/reset`)
5206
+ : { status: 200, body: host?.agent };
5207
+ }
5208
+ return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, {
5209
+ ...code,
5210
+ ...Object.fromEntries(overlay.map(field => [field, definition[field]])),
5211
+ id: agent.id,
5212
+ mcpServerIds: host?.agent.mcpServerIds ?? [],
5213
+ expectedVersion: host?.agent.version ?? 1
5214
+ });
5215
+ }
5216
+ const worksOn = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
5217
+ const input = {
5218
+ id: agent.id,
5219
+ displayName: definition.displayName ?? '',
5220
+ description: definition.description ?? '',
5221
+ instructions: definition.instructions ?? '',
5222
+ ...(definition.instructionsAsset ? { instructionsAsset: definition.instructionsAsset } : {}),
5223
+ toolSelectors: definition.toolSelectors ?? [],
5224
+ mcpServerIds: host?.agent.mcpServerIds ?? [],
5225
+ requiredPolicy: definition.requiredPolicy ?? null,
5226
+ autonomy: definition.autonomy ?? 'explain',
5227
+ ...(definition.offeredInChat !== undefined && definition.offeredInChat !== null ? { offeredInChat: definition.offeredInChat } : {}),
5228
+ ...(worksOn.some(field => definition[field] !== undefined && definition[field] !== null)
5229
+ ? Object.fromEntries(worksOn.map(field => [field, definition[field] ?? null]))
5230
+ : {}),
5231
+ ...(definition.profile ? { profile: definition.profile } : {}),
5232
+ ...(definition.dataClassification ? { dataClassification: definition.dataClassification } : {})
5233
+ };
5234
+ if (!host) {
5235
+ return this.context.call('POST', 'admin/agents', {
5236
+ ...input,
5237
+ ...(definition.startsOff ? { startsOff: true } : {}),
5238
+ ...(definition.startsInShadow ? { startsInShadow: true } : {}),
5239
+ ...(definition.shadowOf ? { shadowOf: definition.shadowOf } : {})
5240
+ });
5241
+ }
5242
+ return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, { ...input, expectedVersion: host.agent.version });
5243
+ }
5244
+ /**
5245
+ * Links the parts of an import: each part the import applied or found unchanged, with its locks and
5246
+ * the hash of the host's part once the package's version is in effect; parts that stayed local stay
5247
+ * so, relinked ones are linked again, and what the package left behind is unlinked.
5248
+ */
5249
+ link(agentPackage, options, planned) {
5250
+ const packageId = agentPackage.package.id;
5251
+ const version = agentPackage.package.version;
5252
+ const now = this.context.now();
5253
+ for (const agent of agentPackage.agents) {
5254
+ const previous = this.links.get(agent.id);
5255
+ const kept = previous?.packageId === packageId ? previous : undefined;
5256
+ const relinked = (options.relink ?? []).filter(item => item.agentId === agent.id);
5257
+ const local = new Set([...(kept?.local ?? [])].filter(part => !relinked.some(item => item.part === null || item.part === undefined || item.part === part)));
5258
+ if (relinked.some(item => item.part === null || item.part === undefined)) {
5259
+ local.clear();
5260
+ }
5261
+ const parts = new Map();
5262
+ for (const item of planned.filter(entry => entry.agentId === agent.id)) {
5263
+ if (item.outcome === 'kept-local' || item.outcome === 'left-behind' || item.outcome === 'refused') {
5264
+ continue;
5265
+ }
5266
+ const previousLink = kept?.parts.get(item.part);
5267
+ parts.set(item.part, {
5268
+ lockedFields: [...item.lockedFields],
5269
+ hash: item.hashAfter ?? previousLink?.hash ?? '',
5270
+ view: item.viewAfter ?? previousLink?.view ?? null,
5271
+ packageVersion: version,
5272
+ linkedAt: item.outcome === 'unchanged' && previousLink ? previousLink.linkedAt : now,
5273
+ linkedBy: item.outcome === 'unchanged' && previousLink ? previousLink.linkedBy : 'current-user'
5274
+ });
5275
+ }
5276
+ this.links.set(agent.id, { packageId, packageVersion: version, parts, local });
5277
+ }
5278
+ for (const item of planned.filter(entry => entry.outcome === 'left-behind' && entry.part === 'agent')) {
5279
+ this.links.delete(item.agentId);
5280
+ }
5281
+ this.installed.set(packageId, { version, agents: agentPackage.agents.map(agent => agent.id) });
5282
+ }
5283
+ /** `POST admin/agents/{id}/package/unlink`: one part, or without one the whole agent, becomes local. */
5284
+ unlink(agentId, body) {
5285
+ if (!this.read(agentId)) {
5286
+ return mockFailure('assistant-agent-not-found');
5287
+ }
5288
+ const request = (body ?? {});
5289
+ const part = request.part === undefined || request.part === null ? null : request.part;
5290
+ if (part !== null && (typeof part !== 'string' || !validPartKey(part))) {
5291
+ return mockFailure('assistant-validation', { part: ['invalid'] });
5292
+ }
5293
+ if (request.reason !== undefined && request.reason !== null && (typeof request.reason !== 'string' || request.reason.length > mockMaxStateReason)) {
5294
+ return mockFailure('assistant-validation', { reason: ['invalid'] });
5295
+ }
5296
+ const link = this.links.get(agentId);
5297
+ if (!link || link.parts.size === 0 || (part !== null && !link.parts.has(part))) {
5298
+ return mockFailure('assistant-package-not-linked');
5299
+ }
5300
+ const unlinked = part === null ? [...link.parts.keys()] : [part];
5301
+ for (const key of unlinked) {
5302
+ link.parts.delete(key);
5303
+ link.local.add(key);
5304
+ }
5305
+ if (part === null) {
5306
+ link.local.add(NH_ASSISTANT_PACKAGE_LOCK_ALL);
5307
+ }
5308
+ return { status: 200, body: { agentId, packageId: link.packageId, parts: unlinked.sort() } };
5309
+ }
5310
+ /** Whether the agent still has a linked part, so it cannot be deleted or reset. */
5311
+ isLinked(agentId) {
5312
+ return (this.links.get(agentId)?.parts.size ?? 0) > 0;
5313
+ }
5314
+ /** The agent with the package it belongs to, while a part of it is linked. */
5315
+ withAgentPackage(agent) {
5316
+ const link = this.links.get(agent?.id);
5317
+ if (!link || link.parts.size === 0) {
5318
+ return agent;
5319
+ }
5320
+ const read = this.read(agent.id);
5321
+ const value = {
5322
+ packageId: link.packageId,
5323
+ packageVersion: link.packageVersion,
5324
+ parts: [...link.parts.keys()].sort().map(part => this.partLinkOf(agent.id, part, link, read))
5325
+ };
5326
+ return { ...agent, package: value };
5327
+ }
5328
+ /** The item with the link of its part, or as it is when the part is not linked. */
5329
+ withLink(agentId, part, item) {
5330
+ const link = this.links.get(agentId);
5331
+ const value = link ? this.partLinkOf(agentId, part, link, null) : null;
5332
+ return value ? { ...item, package: value } : item;
5333
+ }
5334
+ partLinkOf(agentId, part, link, read) {
5335
+ const partLink = link.parts.get(part);
5336
+ if (!partLink) {
5337
+ return null;
5338
+ }
5339
+ const host = read ?? this.read(agentId);
5340
+ return {
5341
+ part,
5342
+ packageId: link.packageId,
5343
+ packageVersion: partLink.packageVersion,
5344
+ lockedFields: [...partLink.lockedFields],
5345
+ hash: partLink.hash,
5346
+ changedLocally: host ? this.hostHash(host, part) !== partLink.hash : false,
5347
+ linkedAt: partLink.linkedAt,
5348
+ linkedBy: partLink.linkedBy
5349
+ };
5350
+ }
5351
+ /** What the mock has of one agent, read through its own endpoints; `null` for an unknown agent. */
5352
+ read(agentId) {
5353
+ const agent = this.agents().find(item => item.id === agentId);
5354
+ if (!agent) {
5355
+ return null;
5356
+ }
5357
+ const path = `admin/agents/${encodeURIComponent(agentId)}`;
5358
+ const configuration = this.context.call('GET', `${path}/configuration`);
5359
+ const handOffs = this.context.call('GET', `${path}/hand-offs`);
5360
+ const metadata = this.context.call('GET', `${path}/metadata`);
5361
+ return {
5362
+ agent,
5363
+ configuration: configuration.status === 200 ? configuration.body : null,
5364
+ handOffs: handOffs.status === 200 ? handOffs.body : null,
5365
+ metadata: metadata.status === 200 ? metadata.body : []
5366
+ };
5367
+ }
5368
+ agents() {
5369
+ const result = this.context.call('GET', 'admin/agents');
5370
+ return result.status === 200 ? result.body : [];
5371
+ }
5372
+ /** The SHA-256 of the canonical JSON of the host's part as a package holds it; `null` without the part. */
5373
+ hostHash(read, part) {
5374
+ const value = partsOf(this.packageAgentOf(read)).get(part);
5375
+ return value === undefined ? null : mockSha256(canonicalJson(value));
5376
+ }
5377
+ /**
5378
+ * What a save may not change of a part: the agent's definition as it is in effect (for an agent in
5379
+ * code with its code's values), or the part as a package holds it.
5380
+ */
5381
+ lockView(agentId, part) {
5382
+ const read = this.read(agentId);
5383
+ if (!read) {
5384
+ return null;
5385
+ }
5386
+ if (part === 'agent') {
5387
+ return agentDefinitionOf(read.agent);
5388
+ }
5389
+ const value = partsOf(this.packageAgentOf(read)).get(part);
5390
+ return value === undefined ? null : part.startsWith('metadata:') ? { value } : value;
5391
+ }
5392
+ /** The agent as a package holds it, without locks and hashes. */
5393
+ packageAgentOf(read) {
5394
+ const { agent, configuration, handOffs, metadata } = read;
5395
+ const inCode = agent.source === 'code';
5396
+ const definition = inCode ? this.overlayOf(agent) : agentDefinitionOf(agent);
5397
+ const values = metadata.filter(entry => entry.source === (inCode ? 'override' : 'data'));
5398
+ // The projections are the format's JSON; the format's types name the same properties.
5399
+ const packageAgent = {
5400
+ id: agent.id,
5401
+ source: agent.source,
5402
+ ...(definition && Object.keys(definition).length > 0 ? { definition } : {}),
5403
+ ...(!inCode && configuration?.runSettings ? { runSettings: runSettingsOf(configuration.runSettings) } : {}),
5404
+ ...nonEmpty('skills', (configuration?.dataSkills ?? []).map(skillOf)),
5405
+ ...nonEmpty('triggers', (configuration?.dataTriggers ?? []).map(triggerOf)),
5406
+ ...nonEmpty('consultations', (configuration?.dataConsultations ?? []).map(consultationOf)),
5407
+ ...nonEmpty('handOffs', (handOffs?.dataHandOffs ?? []).map(handOff => ({
5408
+ subjectType: handOff.subjectType,
5409
+ sources: [...handOff.sources],
5410
+ description: handOff.description,
5411
+ notifySource: handOff.notifySource,
5412
+ maxPerSubjectPerDay: handOff.maxPerSubjectPerDay
5413
+ })))
5414
+ };
5415
+ if (values.length > 0) {
5416
+ packageAgent['metadata'] = Object.fromEntries(values.map(entry => [entry.name, entry.value]));
5417
+ }
5418
+ if (!agent.shadowOf && (agent.groups?.length ?? 0) > 0) {
5419
+ packageAgent['groups'] = [...agent.groups];
5420
+ }
5421
+ return packageAgent;
5422
+ }
5423
+ /** The overrides of an agent in code: the fields of its definition that differ from its code. */
5424
+ overlayOf(agent) {
5425
+ const code = this.context.codeDefinition(agent.id);
5426
+ if (!code || !agent.isOverridden) {
5427
+ return {};
5428
+ }
5429
+ const definition = agent;
5430
+ const inCode = code;
5431
+ return Object.fromEntries(codeAgentFields
5432
+ .filter(field => !sameValue(definition[field], inCode[field]))
5433
+ .map(field => [field, definition[field]]));
5434
+ }
5435
+ }
5436
+ /** The part of the administration a request saves or removes, or the whole agent it deletes or resets. */
5437
+ function guardedPart(method, segments) {
5438
+ if (segments[0] !== 'admin' || segments[1] !== 'agents' || segments[2] === undefined) {
5439
+ return null;
5440
+ }
5441
+ const [, , agentId, area, id, action, child] = segments;
5442
+ if (segments.length === 3) {
5443
+ return method === 'PUT' ? { agentId, part: 'agent', removal: false } : method === 'DELETE' ? { agentId, part: null, removal: true } : null;
5444
+ }
5445
+ // A reset restores the code's definition, which the locks keep as the package has it.
5446
+ if (area === 'reset' && segments.length === 4) {
5447
+ return method === 'POST' ? { agentId, part: 'agent', removal: false, reset: true } : null;
5448
+ }
5449
+ if (area === 'run-settings' && segments.length === 4) {
5450
+ return method === 'PUT' || method === 'DELETE' ? { agentId, part: 'runSettings', removal: method === 'DELETE' } : null;
5451
+ }
5452
+ const kinds = {
5453
+ skills: 'skill',
5454
+ triggers: 'trigger',
5455
+ consultations: 'consultation',
5456
+ 'hand-offs': 'handOff',
5457
+ metadata: 'metadata'
5458
+ };
5459
+ const kind = area === undefined ? undefined : kinds[area];
5460
+ if (kind === undefined || id === undefined) {
5461
+ return null;
5462
+ }
5463
+ const part = `${kind}:${id}`;
5464
+ if (segments.length === 5) {
5465
+ return method === 'PUT' || method === 'DELETE' ? { agentId, part, removal: method === 'DELETE' } : null;
5466
+ }
5467
+ // Promoting a candidate makes it the live version, which the locks keep as the package has it.
5468
+ if ((kind === 'skill' || kind === 'trigger') && action === 'candidate' && child === 'promote' && segments.length === 7 && method === 'POST') {
5469
+ return { agentId, part, removal: false };
5470
+ }
5471
+ return null;
5472
+ }
5473
+ /**
5474
+ * The locked fields a save changed to something else than the package's value: promoting the
5475
+ * candidate an import made of the package's version is no change of a locked field.
5476
+ */
5477
+ function lockedChanges(kind, locked, before, after, packaged) {
5478
+ const fields = locked.includes(NH_ASSISTANT_PACKAGE_LOCK_ALL) ? NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind] : locked;
5479
+ return fields.filter(field => {
5480
+ const properties = lockProperties[kind][field] ?? [field];
5481
+ const changed = properties.some(property => !sameValue(before?.[property], after?.[property]));
5482
+ const asPackaged = packaged !== null && properties.every(property => sameValue(after?.[property], packaged[property]));
5483
+ return changed && !asPackaged;
5484
+ });
5485
+ }
5486
+ /** Why a lock of a package agent is invalid: `not-found` for a part it does not hold, `invalid` fields. */
5487
+ function lockProblem(agent, part, fields) {
5488
+ if (fields.length === 0) {
5489
+ return null;
5490
+ }
5491
+ if (!partsOf(agent).has(part)) {
5492
+ return 'not-found';
5493
+ }
5494
+ const kind = kindOf(part);
5495
+ const allowed = kind === 'agent' && agent.source === 'code'
5496
+ ? NH_ASSISTANT_PACKAGE_LOCK_FIELDS.agent.slice(0, codeAgentFields.length)
5497
+ : NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind];
5498
+ const whole = fields.length === 1 && fields[0] === NH_ASSISTANT_PACKAGE_LOCK_ALL;
5499
+ return whole || fields.every(field => allowed.includes(field)) ? null : 'invalid';
5500
+ }
5501
+ /**
5502
+ * Why the agent of a package may not be imported onto the host's agent with its id, or `null`: an
5503
+ * agent of another package, an agent in code for an agent created here, or an agent created here that
5504
+ * the package neither linked nor takes over (`relink` naming the whole agent).
5505
+ */
5506
+ function agentRefusal(agent, host, linkedToPackage, otherPackage) {
5507
+ if (otherPackage) {
5508
+ return 'assistant-agent-exists';
5509
+ }
5510
+ if (agent.source === 'code') {
5511
+ if (!host) {
5512
+ return 'assistant-agent-not-found';
5513
+ }
5514
+ return host.source === 'code' ? null : 'assistant-agent-not-code';
5515
+ }
5516
+ if (host && (host.source === 'code' || !linkedToPackage)) {
5517
+ return 'assistant-agent-exists';
5518
+ }
5519
+ return null;
5520
+ }
5521
+ /** The parts of a package agent by part key, in the format's order. */
5522
+ function partsOf(agent) {
5523
+ const parts = new Map();
5524
+ if (agent.definition) {
5525
+ parts.set('agent', agent.definition);
5526
+ }
5527
+ if (agent.runSettings) {
5528
+ parts.set('runSettings', agent.runSettings);
5529
+ }
5530
+ for (const skill of agent.skills ?? []) {
5531
+ parts.set(`skill:${skill.id}`, skill);
5532
+ }
5533
+ for (const trigger of agent.triggers ?? []) {
5534
+ parts.set(`trigger:${trigger.id}`, trigger);
5535
+ }
5536
+ for (const consultation of agent.consultations ?? []) {
5537
+ parts.set(`consultation:${consultation.subjectType}`, consultation);
5538
+ }
5539
+ for (const handOff of agent.handOffs ?? []) {
5540
+ parts.set(`handOff:${handOff.subjectType}`, handOff);
5541
+ }
5542
+ for (const [name, value] of Object.entries(agent.metadata ?? {})) {
5543
+ parts.set(`metadata:${name}`, value);
5544
+ }
5545
+ return parts;
5546
+ }
5547
+ function kindOf(part) {
5548
+ const colon = part.indexOf(':');
5549
+ return (colon < 0 ? part : part.slice(0, colon));
5550
+ }
5551
+ function keyOf(part) {
5552
+ const colon = part.indexOf(':');
5553
+ return colon < 0 ? '' : part.slice(colon + 1);
5554
+ }
5555
+ /** Whether a part key names a known kind, with a dash-case key for the kinds that take one. */
5556
+ function validPartKey(part) {
5557
+ if (part === 'agent' || part === 'runSettings') {
5558
+ return true;
5559
+ }
5560
+ const kind = kindOf(part);
5561
+ return ['skill', 'trigger', 'consultation', 'handOff', 'metadata'].includes(kind) && mockDashCase.test(keyOf(part));
5562
+ }
5563
+ /** The definition of an agent as a package holds an agent created here. */
5564
+ function agentDefinitionOf(agent) {
5565
+ return {
5566
+ displayName: agent.displayName,
5567
+ description: agent.description,
5568
+ instructions: agent.instructionsAsset ? null : agent.instructions,
5569
+ instructionsAsset: agent.instructionsAsset ?? null,
5570
+ toolSelectors: [...agent.toolSelectors],
5571
+ requiredPolicy: agent.requiredPolicy,
5572
+ autonomy: agent.autonomy,
5573
+ offeredInChat: agent.offeredInChat ?? true,
5574
+ worksOnSubjectTypes: agent.worksOnSubjectTypes ?? null,
5575
+ worksOnAllSubjects: agent.worksOnAllSubjects ?? null,
5576
+ worksOnApplication: agent.worksOnApplication ?? null,
5577
+ perDivision: agent.perDivision ?? null,
5578
+ runPolicy: agent.runPolicy ?? null,
5579
+ viewPolicy: agent.viewPolicy ?? null,
5580
+ profile: agent.profile ?? null,
5581
+ dataClassification: agent.dataClassification ?? null,
5582
+ shadowOf: agent.shadowOf ?? null
5583
+ };
5584
+ }
5585
+ function runSettingsOf(settings) {
5586
+ return {
5587
+ accountability: settings.accountability,
5588
+ delegateFromInitiator: settings.delegateFromInitiator,
5589
+ allowGatewayReads: settings.allowGatewayReads,
5590
+ elevatedReads: settings.elevatedReads,
5591
+ approvalPolicy: settings.approvalPolicy,
5592
+ retryCount: settings.retryCount,
5593
+ dailyToolCalls: settings.dailyToolCalls,
5594
+ dailyTokens: settings.dailyTokens,
5595
+ maxConcurrentRuns: settings.maxConcurrentRuns,
5596
+ maxRunsPerSubjectPerDay: settings.maxRunsPerSubjectPerDay,
5597
+ maxTokensPerRun: settings.maxTokensPerRun,
5598
+ triageProfile: settings.triageProfile,
5599
+ ruleHandler: settings.ruleHandler,
5600
+ acceptsFiles: settings.acceptsFiles ?? false,
5601
+ maxFileBytes: settings.maxFileBytes ?? null,
5602
+ maxFilePages: settings.maxFilePages ?? null,
5603
+ maxFilesPerTurn: settings.maxFilesPerTurn ?? null,
5604
+ maxFileTokensPerTurn: settings.maxFileTokensPerTurn ?? null
5605
+ };
5606
+ }
5607
+ function skillOf(skill) {
5608
+ return {
5609
+ id: skill.skillId,
5610
+ description: skill.description,
5611
+ instructions: skill.instructionsAsset ? null : skill.instructions,
5612
+ instructionsAsset: skill.instructionsAsset ?? null,
5613
+ tools: [...skill.tools],
5614
+ profile: skill.profile,
5615
+ ruleHandler: skill.ruleHandler,
5616
+ maxTokensPerRun: skill.maxTokensPerRun,
5617
+ dailyToolCalls: skill.dailyToolCalls,
5618
+ dailyTokens: skill.dailyTokens,
5619
+ approvalPolicy: skill.approvalPolicy,
5620
+ dataClassification: skill.dataClassification ?? null,
5621
+ acceptsFiles: skill.acceptsFiles ?? false,
5622
+ maxFileBytes: skill.maxFileBytes ?? null,
5623
+ maxFilePages: skill.maxFilePages ?? null,
5624
+ maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
5625
+ maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null
5626
+ };
5627
+ }
5628
+ function triggerOf(trigger) {
5629
+ return {
5630
+ id: trigger.triggerId,
5631
+ source: trigger.source,
5632
+ skill: trigger.skillId,
5633
+ event: trigger.event,
5634
+ filters: [...(trigger.filters ?? [])],
5635
+ cron: trigger.cron,
5636
+ timeZone: trigger.timeZone,
5637
+ subjectSet: trigger.subjectSet,
5638
+ message: trigger.message,
5639
+ conflict: trigger.conflict,
5640
+ debounceSeconds: trigger.debounceSeconds
5641
+ };
5642
+ }
5643
+ function consultationOf(consultation) {
5644
+ return {
5645
+ subjectType: consultation.subjectType,
5646
+ description: consultation.description,
5647
+ instructions: consultation.instructions,
5648
+ minimumRole: consultation.minimumRole,
5649
+ askerPolicy: consultation.askerPolicy,
5650
+ routes: [...consultation.routes],
5651
+ proposalRole: consultation.proposalRole,
5652
+ proposalPolicy: consultation.proposalPolicy,
5653
+ toolSelectors: consultation.toolSelectors ?? null,
5654
+ maxToolCalls: consultation.maxToolCalls,
5655
+ timeoutSeconds: consultation.timeoutSeconds,
5656
+ maxAnswerChars: consultation.maxAnswerChars,
5657
+ maxHistoryMessages: consultation.maxHistoryMessages,
5658
+ allowRequests: consultation.allowRequests,
5659
+ exposeToMcp: consultation.exposeToMcp,
5660
+ dataClassification: consultation.dataClassification
5661
+ };
5662
+ }
5663
+ /** `{ [name]: items }` for a list with items, otherwise nothing, as the format leaves empty lists out. */
5664
+ function nonEmpty(name, items) {
5665
+ return items.length > 0 ? { [name]: items } : {};
5666
+ }
5667
+ /**
5668
+ * Reads a package strictly, as the server does: `assistant-package-too-large` above 1 MB or 20
5669
+ * agents; `assistant-package-format-unsupported` for another format, a newer format version, an
5670
+ * unknown property, a missing required one or an agent id given twice.
5671
+ */
5672
+ function readPackage(value) {
5673
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5674
+ return mockFailure('assistant-package-format-unsupported');
5675
+ }
5676
+ if (new TextEncoder().encode(JSON.stringify(value)).length > NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES) {
5677
+ return mockFailure('assistant-package-too-large');
5678
+ }
5679
+ const root = value;
5680
+ if (root['format'] !== NH_ASSISTANT_AGENT_PACKAGE_FORMAT
5681
+ || typeof root['formatVersion'] !== 'number'
5682
+ || !Number.isInteger(root['formatVersion'])
5683
+ || root['formatVersion'] < 1
5684
+ || root['formatVersion'] > NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION) {
5685
+ return mockFailure('assistant-package-format-unsupported');
5686
+ }
5687
+ const agents = root['agents'];
5688
+ if (!fits(root, 'root') || !fits(root['package'], 'package') || !Array.isArray(agents)) {
5689
+ return mockFailure('assistant-package-format-unsupported');
5690
+ }
5691
+ if (agents.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
5692
+ return mockFailure('assistant-package-too-large');
5693
+ }
5694
+ const info = root['package'];
5695
+ const ids = new Set();
5696
+ for (const item of agents) {
5697
+ if (!fitsAgent(item)) {
5698
+ return mockFailure('assistant-package-format-unsupported');
5699
+ }
5700
+ const id = item['id'];
5701
+ if (ids.has(id)) {
5702
+ return mockFailure('assistant-package-format-unsupported');
5703
+ }
5704
+ ids.add(id);
5705
+ }
5706
+ if (agents.length === 0) {
5707
+ return mockFailure('assistant-package-format-unsupported');
5708
+ }
5709
+ const errors = {};
5710
+ if (typeof info['id'] !== 'string' || !mockDashCase.test(info['id']) || info['id'].length > maxPackageId) {
5711
+ errors['package.id'] = ['invalid'];
5712
+ }
5713
+ if (typeof info['version'] !== 'number' || !Number.isInteger(info['version']) || info['version'] < 1) {
5714
+ errors['package.version'] = ['invalid'];
5715
+ }
5716
+ if (info['description'] !== undefined && info['description'] !== null
5717
+ && (typeof info['description'] !== 'string' || info['description'].length > maxPackageDescription)) {
5718
+ errors['package.description'] = ['too-long'];
5719
+ }
5720
+ return Object.keys(errors).length > 0 ? mockFailure('assistant-validation', errors) : { package: structuredClone(value) };
5721
+ }
5722
+ function fitsAgent(value) {
5723
+ if (!fits(value, 'agent')) {
5724
+ return false;
5725
+ }
5726
+ const agent = value;
5727
+ if (typeof agent['id'] !== 'string' || (agent['source'] !== 'admin' && agent['source'] !== 'code')) {
5728
+ return false;
5729
+ }
5730
+ if (agent['source'] === 'admin' && (agent['definition'] === undefined || agent['definition'] === null)) {
5731
+ return false;
5732
+ }
5733
+ const definition = agent['definition'];
5734
+ if (definition !== undefined && definition !== null
5735
+ && (!fits(definition, 'definition') || (definition['instructionsAsset'] && !fits(definition['instructionsAsset'], 'instructionsAsset')))) {
5736
+ return false;
5737
+ }
5738
+ if (agent['runSettings'] !== undefined && agent['runSettings'] !== null && !fits(agent['runSettings'], 'runSettings')) {
5739
+ return false;
5740
+ }
5741
+ const lists = [['skills', 'skill'], ['triggers', 'trigger'], ['consultations', 'consultation'], ['handOffs', 'handOff']];
5742
+ for (const [list, shape] of lists) {
5743
+ const items = agent[list];
5744
+ if (items !== undefined && items !== null && (!Array.isArray(items) || !items.every(item => fits(item, shape)))) {
5745
+ return false;
5746
+ }
5747
+ }
5748
+ for (const map of ['metadata', 'locks', 'hashes']) {
5749
+ const item = agent[map];
5750
+ if (item !== undefined && item !== null && (typeof item !== 'object' || Array.isArray(item))) {
5751
+ return false;
5752
+ }
5753
+ }
5754
+ const groups = agent['groups'];
5755
+ return groups === undefined || groups === null || (Array.isArray(groups) && groups.every(group => typeof group === 'string'));
5756
+ }
5757
+ /** Whether an object has every required property of its shape and none it does not know. */
5758
+ function fits(value, shape) {
5759
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
5760
+ return false;
5761
+ }
5762
+ const { required, optional } = formatShapes[shape];
5763
+ const record = value;
5764
+ return required.every(name => record[name] !== undefined && record[name] !== null)
5765
+ && Object.keys(record).every(name => required.includes(name) || optional.includes(name));
5766
+ }
5767
+ function base(agentId, part, lockedFields) {
5768
+ return {
5769
+ agentId,
5770
+ part,
5771
+ outcome: 'unchanged',
5772
+ changedLocally: false,
5773
+ code: null,
5774
+ errors: null,
5775
+ lockedFields: [...lockedFields],
5776
+ candidateVersion: null,
5777
+ hashAfter: null,
5778
+ viewAfter: null
5779
+ };
5780
+ }
5781
+ function leftBehind(agentId, part) {
5782
+ return { ...base(agentId, part, []), outcome: 'left-behind' };
5783
+ }
5784
+ /** A refused part with the endpoint's field errors, or its code without them. */
5785
+ function refusalOf(agentId, part, result, lockedFields) {
5786
+ const body = (result.body ?? {});
5787
+ const errors = body.errors && Object.keys(body.errors).length > 0 ? body.errors : null;
5788
+ return {
5789
+ ...base(agentId, part, lockedFields),
5790
+ outcome: 'refused',
5791
+ code: errors ? null : body.code ?? 'assistant-validation',
5792
+ errors
5793
+ };
5794
+ }
5795
+ /**
5796
+ * The field errors of `assistant-package-refused`, as the server answers them: one plain field, since
5797
+ * the preview's part outcomes say why each part was refused.
5798
+ */
5799
+ const refusedErrors = { parts: ['invalid'] };
5800
+ function publicPart({ hashAfter: _, viewAfter: __, ...part }) {
5801
+ return part;
5802
+ }
5803
+ /** A trimmed text, or `null` for none. */
5804
+ function text(value) {
5805
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
5806
+ }
5807
+ /** Whether two values are the same as a package holds them: `null` and missing alike. */
5808
+ function sameValue(left, right) {
5809
+ return canonicalJson(left ?? null) === canonicalJson(right ?? null);
5810
+ }
5811
+ /** A copy of a value without `null` and `undefined` properties, as the format writes it. */
5812
+ function withoutNulls(value) {
5813
+ if (Array.isArray(value)) {
5814
+ return value.map(item => withoutNulls(item));
5815
+ }
5816
+ if (value && typeof value === 'object') {
5817
+ return Object.fromEntries(Object.entries(value)
5818
+ .filter(([, item]) => item !== null && item !== undefined)
5819
+ .map(([key, item]) => [key, withoutNulls(item)]));
5820
+ }
5821
+ return value;
5822
+ }
5823
+ /**
5824
+ * The canonical JSON of a value, as the format hashes a part: object properties ordered by name
5825
+ * (ordinal), properties with a `null` value left out, no white space.
5826
+ */
5827
+ function canonicalJson(value) {
5828
+ if (value === null || value === undefined) {
5829
+ return 'null';
5830
+ }
5831
+ if (Array.isArray(value)) {
5832
+ return `[${value.map(item => canonicalJson(item)).join(',')}]`;
5833
+ }
5834
+ if (typeof value === 'object') {
5835
+ const entries = Object.entries(value)
5836
+ .filter(([, item]) => item !== null && item !== undefined)
5837
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
5838
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
5839
+ }
5840
+ return JSON.stringify(value);
5841
+ }
5842
+ const sha256Constants = new Uint32Array([
5843
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
5844
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
5845
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
5846
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
5847
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
5848
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
5849
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
5850
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
5851
+ ]);
5852
+ /**
5853
+ * The SHA-256 of the UTF-8 bytes of a text, lowercase hex, computed synchronously so an import can
5854
+ * hash its parts between two of its writes.
5855
+ */
5856
+ function mockSha256(input) {
5857
+ const bytes = new TextEncoder().encode(input);
5858
+ const length = ((bytes.length + 9 + 63) >> 6) << 6;
5859
+ const data = new Uint8Array(length);
5860
+ data.set(bytes);
5861
+ data[bytes.length] = 0x80;
5862
+ const view = new DataView(data.buffer);
5863
+ view.setUint32(length - 8, Math.floor(bytes.length / 0x20000000));
5864
+ view.setUint32(length - 4, (bytes.length << 3) >>> 0);
5865
+ const hash = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
5866
+ const words = new Uint32Array(64);
5867
+ const rotate = (value, bits) => (value >>> bits) | (value << (32 - bits));
5868
+ for (let offset = 0; offset < length; offset += 64) {
5869
+ for (let index = 0; index < 16; index++) {
5870
+ words[index] = view.getUint32(offset + index * 4);
5871
+ }
5872
+ for (let index = 16; index < 64; index++) {
5873
+ const low = words[index - 15];
5874
+ const high = words[index - 2];
5875
+ const s0 = rotate(low, 7) ^ rotate(low, 18) ^ (low >>> 3);
5876
+ const s1 = rotate(high, 17) ^ rotate(high, 19) ^ (high >>> 10);
5877
+ words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
5878
+ }
5879
+ let [a, b, c, d, e, f, g, h] = hash;
5880
+ for (let index = 0; index < 64; index++) {
5881
+ const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
5882
+ const choice = (e & f) ^ (~e & g);
5883
+ const first = (h + s1 + choice + sha256Constants[index] + words[index]) >>> 0;
5884
+ const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
5885
+ const majority = (a & b) ^ (a & c) ^ (b & c);
5886
+ const second = (s0 + majority) >>> 0;
5887
+ h = g;
5888
+ g = f;
5889
+ f = e;
5890
+ e = (d + first) >>> 0;
5891
+ d = c;
5892
+ c = b;
5893
+ b = a;
5894
+ a = (first + second) >>> 0;
5895
+ }
5896
+ hash[0] = (hash[0] + a) >>> 0;
5897
+ hash[1] = (hash[1] + b) >>> 0;
5898
+ hash[2] = (hash[2] + c) >>> 0;
5899
+ hash[3] = (hash[3] + d) >>> 0;
5900
+ hash[4] = (hash[4] + e) >>> 0;
5901
+ hash[5] = (hash[5] + f) >>> 0;
5902
+ hash[6] = (hash[6] + g) >>> 0;
5903
+ hash[7] = (hash[7] + h) >>> 0;
5904
+ }
5905
+ return [...hash].map(word => word.toString(16).padStart(8, '0')).join('');
5906
+ }
5907
+
3758
5908
  /** The longest file name a reference takes, as on the server. */
3759
5909
  const maxNameLength = 256;
3760
5910
  const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
@@ -3777,7 +5927,7 @@ const binarySignatures = [
3777
5927
  ];
3778
5928
  /** How files of the platform's types start; other accepted types are the application's to check. */
3779
5929
  const signatures = {
3780
- 'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
5930
+ 'application/pdf': head => indexOf$1(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
3781
5931
  'image/png': head => startsWith(head, pngSignature),
3782
5932
  'image/jpeg': head => startsWith(head, jpegSignature),
3783
5933
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, zipSignature),
@@ -3936,7 +6086,7 @@ function refusalCode(code) {
3936
6086
  function startsWith(head, signature) {
3937
6087
  return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
3938
6088
  }
3939
- function indexOf(head, signature) {
6089
+ function indexOf$1(head, signature) {
3940
6090
  for (let start = 0; start + signature.length <= head.length; start++) {
3941
6091
  if (signature.every((byte, index) => head[start + index] === byte)) {
3942
6092
  return start;
@@ -3945,7 +6095,7 @@ function indexOf(head, signature) {
3945
6095
  return -1;
3946
6096
  }
3947
6097
 
3948
- const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6098
+ const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
3949
6099
  const minQueryLength = 2;
3950
6100
  const maxQueryLength = 100;
3951
6101
  const defaultLimit = 20;
@@ -3972,7 +6122,7 @@ class NhAssistantMockSubjects {
3972
6122
  this.openProposals = openProposals;
3973
6123
  const ids = new Set();
3974
6124
  for (const type of types) {
3975
- if (!dashCase$3.test(type.id) || type.id.length > 100) {
6125
+ if (!dashCase$4.test(type.id) || type.id.length > 100) {
3976
6126
  throw new Error(`Mock subject type '${type.id}' is not a dash-case id of at most 100 characters.`);
3977
6127
  }
3978
6128
  if (type.id.startsWith('nh-')) {
@@ -4128,7 +6278,7 @@ class NhAssistantMockSubjects {
4128
6278
  * subject not found.
4129
6279
  */
4130
6280
  work(typeId, subjectId, language) {
4131
- if (!dashCase$3.test(typeId) || typeId.length > 100 || subjectId.trim().length === 0 || subjectId.length > 200 || subjectId.trim() !== subjectId) {
6281
+ if (!dashCase$4.test(typeId) || typeId.length > 100 || subjectId.trim().length === 0 || subjectId.length > 200 || subjectId.trim() !== subjectId) {
4132
6282
  return mockFailure('assistant-subject-invalid');
4133
6283
  }
4134
6284
  if (typeId === systemType$1) {
@@ -4245,7 +6395,7 @@ function isValidLinkTemplate(template) {
4245
6395
  return pathStart > 0 && template.indexOf(placeholder) > pathStart;
4246
6396
  }
4247
6397
 
4248
- const dashCase$2 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
6398
+ const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
4249
6399
  const maxMessage = 8_000;
4250
6400
  const maxSubjectId = 200;
4251
6401
  const maxAgentId = 128;
@@ -4963,7 +7113,7 @@ function key(agentId, subject) {
4963
7113
  return `${agentId}|${subject.subjectType}|${subject.subjectId}`;
4964
7114
  }
4965
7115
  function validSubject(type, id) {
4966
- return dashCase$2.test(type) && type.length <= 100 && id.trim().length > 0 && id.length <= maxSubjectId && id.trim() === id;
7116
+ return dashCase$3.test(type) && type.length <= 100 && id.trim().length > 0 && id.length <= maxSubjectId && id.trim() === id;
4967
7117
  }
4968
7118
  /** The type the agent works on, and for the whole application its scope: app-wide or per division. */
4969
7119
  function worksOn(agent, subject) {
@@ -5033,7 +7183,7 @@ function readFilter(query, withAgent) {
5033
7183
  errors['agentId'] = ['invalid'];
5034
7184
  }
5035
7185
  const subjectType = query.get('subjectType');
5036
- if (subjectType !== null && (!dashCase$2.test(subjectType) || subjectType.length > 100)) {
7186
+ if (subjectType !== null && (!dashCase$3.test(subjectType) || subjectType.length > 100)) {
5037
7187
  errors['subjectType'] = ['invalid'];
5038
7188
  }
5039
7189
  const subjectId = query.get('subjectId');
@@ -5063,7 +7213,7 @@ function readFilter(query, withAgent) {
5063
7213
  return { filter: { agentId, subjectType, subjectId, status, from, to, page, itemsPerPage } };
5064
7214
  }
5065
7215
 
5066
- const dashCase$1 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
7216
+ const dashCase$2 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
5067
7217
  const language = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
5068
7218
  const maxGroupId = 64;
5069
7219
  const maxGroups = 100;
@@ -5125,6 +7275,13 @@ class NhAssistantMockBoards {
5125
7275
  }
5126
7276
  return { agentId, groups: [...groups].sort(), codeGroups };
5127
7277
  }
7278
+ /**
7279
+ * A copy of the administrators' group choices; the returned function puts it back, for an import
7280
+ * of an agent package that fails as a whole and for its preview.
7281
+ */
7282
+ snapshot() {
7283
+ return mockSnapshot(this.choices);
7284
+ }
5128
7285
  /** Forgets the choices about an agent that was deleted. */
5129
7286
  forget(agentId) {
5130
7287
  this.choices.delete(agentId);
@@ -5307,7 +7464,7 @@ function validate(groups) {
5307
7464
  }
5308
7465
  const seen = new Set();
5309
7466
  for (const group of groups) {
5310
- if (!dashCase$1.test(group.id) || group.id.length > maxGroupId) {
7467
+ if (!dashCase$2.test(group.id) || group.id.length > maxGroupId) {
5311
7468
  throw new Error(`The mock agent group '${group.id}' is not a dash-case id of at most ${maxGroupId} characters.`);
5312
7469
  }
5313
7470
  if (seen.has(group.id)) {
@@ -5333,6 +7490,222 @@ function checkTexts(groupId, kind, texts, fallback, max) {
5333
7490
  }
5334
7491
  }
5335
7492
 
7493
+ const defaultMaxBytes = 4_096;
7494
+ const dashCase$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7495
+ /**
7496
+ * Agent metadata of the mock API (MD1 to MD3), as the server serves it: `admin.agentMetadata` of the
7497
+ * scenario are the entries registered in code, and `metadata` of each scenario agent its values, in
7498
+ * code for an agent in code and stored at the first version for an agent created as data.
7499
+ * `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}` answer with
7500
+ * the server's checks: an entry of an agent created here makes the agent's next version, an editable
7501
+ * entry of an agent in code is an override with a version of its own, and a read-only one is refused.
7502
+ * Values of `POST` and `PUT admin/agents` are not taken: the agent editor saves each entry through its
7503
+ * slot. Values live as long as the mock.
7504
+ */
7505
+ class NhAssistantMockAgentMetadata {
7506
+ constructor(scenario, context, now) {
7507
+ this.context = context;
7508
+ this.now = now;
7509
+ /** The values in code, by agent and entry. */
7510
+ this.code = new Map();
7511
+ /** The stored values, by agent and entry. */
7512
+ this.stored = new Map();
7513
+ this.entries = [...(scenario.admin?.agentMetadata ?? [])].sort((left, right) => left.name.localeCompare(right.name));
7514
+ for (const entry of this.entries) {
7515
+ if (!dashCase$1.test(entry.name) || entry.name.length > 64 || entry.name.startsWith('nh-')) {
7516
+ throw new Error(`The mock's agent metadata entry '${entry.name}' is not a valid name.`);
7517
+ }
7518
+ }
7519
+ for (const agent of scenario.admin?.agents ?? []) {
7520
+ for (const [name, value] of Object.entries(agent.metadata ?? {})) {
7521
+ const entry = this.entries.find(item => item.name === name);
7522
+ // Startup refuses a value that a save would refuse.
7523
+ const problems = entry ? this.problemsOf(entry, value) : { '': ['not-found'] };
7524
+ if (!entry || Object.keys(problems).length > 0) {
7525
+ throw new Error(`The mock agent '${agent.id}' sets metadata '${name}' that the entries refuse.`);
7526
+ }
7527
+ if (agent.source === 'code') {
7528
+ this.valuesOf(this.code, agent.id).set(name, structuredClone(value));
7529
+ }
7530
+ else {
7531
+ this.valuesOf(this.stored, agent.id).set(name, {
7532
+ value: structuredClone(value),
7533
+ version: 1,
7534
+ changedAt: this.now(),
7535
+ changedBy: 'current-user'
7536
+ });
7537
+ }
7538
+ }
7539
+ }
7540
+ }
7541
+ /** The run catalog's `agentMetadata`. */
7542
+ catalog() {
7543
+ return this.entries.map(entry => ({
7544
+ name: entry.name,
7545
+ editable: entry.editable === true,
7546
+ maxBytes: entry.maxBytes ?? defaultMaxBytes,
7547
+ indexed: entry.indexBy !== undefined
7548
+ }));
7549
+ }
7550
+ /**
7551
+ * A copy of the stored values; the returned function puts it back, for an import of an agent
7552
+ * package that fails as a whole and for its preview.
7553
+ */
7554
+ snapshot() {
7555
+ return mockSnapshot(this.stored);
7556
+ }
7557
+ /** Forgets the values of an agent that was deleted, as the server removes them with it. */
7558
+ forget(agentId) {
7559
+ this.stored.delete(agentId);
7560
+ }
7561
+ /** `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}`. */
7562
+ handle(method, agentId, name, query, body) {
7563
+ if (!this.context.administers()) {
7564
+ return mockFailure('assistant-forbidden');
7565
+ }
7566
+ if (name === undefined) {
7567
+ if (method !== 'GET') {
7568
+ return { status: 405 };
7569
+ }
7570
+ const agent = this.context.agent(agentId);
7571
+ return agent
7572
+ ? { status: 200, body: this.entries.map(entry => this.view(agent, entry)) }
7573
+ : mockFailure('assistant-agent-not-found');
7574
+ }
7575
+ if (method !== 'PUT' && method !== 'DELETE') {
7576
+ return { status: 405 };
7577
+ }
7578
+ const entry = this.entries.find(item => item.name === name);
7579
+ if (!entry) {
7580
+ return mockFailure('assistant-metadata-entry-not-found');
7581
+ }
7582
+ const agent = this.context.agent(agentId);
7583
+ if (!agent) {
7584
+ return mockFailure('assistant-agent-not-found');
7585
+ }
7586
+ if (agent.source === 'code' && entry.editable !== true) {
7587
+ return mockFailure('assistant-metadata-read-only');
7588
+ }
7589
+ return method === 'PUT'
7590
+ ? this.save(agent, entry, body)
7591
+ : this.remove(agent, entry, query.get('expectedVersion'));
7592
+ }
7593
+ save(agent, entry, request) {
7594
+ if (!request || typeof request !== 'object') {
7595
+ return mockFailure('assistant-validation');
7596
+ }
7597
+ const errors = {};
7598
+ for (const [field, codes] of Object.entries(this.problemsOf(entry, request.value))) {
7599
+ errors[field === '' ? 'value' : `value.${field}`] = codes;
7600
+ }
7601
+ const expectedVersion = typeof request.expectedVersion === 'number' ? request.expectedVersion : null;
7602
+ if (agent.source !== 'code' && expectedVersion === null) {
7603
+ errors['expectedVersion'] = ['required'];
7604
+ }
7605
+ if (Object.keys(errors).length > 0) {
7606
+ return mockFailure('assistant-validation', errors);
7607
+ }
7608
+ const values = this.valuesOf(this.stored, agent.id);
7609
+ const current = values.get(entry.name);
7610
+ let version;
7611
+ if (agent.source === 'code') {
7612
+ if (expectedVersion !== (current?.version ?? null)) {
7613
+ return mockFailure('assistant-version-conflict');
7614
+ }
7615
+ version = (current?.version ?? 0) + 1;
7616
+ }
7617
+ else {
7618
+ if (expectedVersion !== agent.version) {
7619
+ return mockFailure('assistant-version-conflict');
7620
+ }
7621
+ version = this.context.nextVersion(agent.id).version;
7622
+ }
7623
+ values.set(entry.name, { value: structuredClone(request.value), version, changedAt: this.now(), changedBy: 'current-user' });
7624
+ return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
7625
+ }
7626
+ remove(agent, entry, expected) {
7627
+ const values = this.valuesOf(this.stored, agent.id);
7628
+ const current = values.get(entry.name);
7629
+ if (!current) {
7630
+ return { status: 200, body: this.view(agent, entry) };
7631
+ }
7632
+ if (expected === null || !/^\d+$/.test(expected)) {
7633
+ return mockFailure('assistant-validation', { expectedVersion: ['required'] });
7634
+ }
7635
+ const expectedVersion = Number(expected);
7636
+ if (expectedVersion !== (agent.source === 'code' ? current.version : agent.version)) {
7637
+ return mockFailure('assistant-version-conflict');
7638
+ }
7639
+ if (agent.source !== 'code') {
7640
+ this.context.nextVersion(agent.id);
7641
+ }
7642
+ values.delete(entry.name);
7643
+ return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
7644
+ }
7645
+ /** The entry of the agent in effect, as `GET admin/agents/{id}/metadata` lists it. */
7646
+ view(agent, entry) {
7647
+ const stored = this.stored.get(agent.id)?.get(entry.name);
7648
+ const code = this.code.get(agent.id)?.get(entry.name);
7649
+ const inCode = agent.source === 'code';
7650
+ const value = stored?.value ?? (inCode ? code : undefined);
7651
+ const source = stored
7652
+ ? (inCode ? 'override' : 'data')
7653
+ : (inCode && code !== undefined ? 'code' : null);
7654
+ return {
7655
+ name: entry.name,
7656
+ editable: !inCode || entry.editable === true,
7657
+ maxBytes: entry.maxBytes ?? defaultMaxBytes,
7658
+ source,
7659
+ value: value === undefined ? null : structuredClone(value),
7660
+ version: inCode ? stored?.version ?? null : agent.version,
7661
+ agentVersion: agent.version,
7662
+ codeValue: inCode && code !== undefined ? structuredClone(code) : null,
7663
+ indexed: entry.indexBy !== undefined,
7664
+ indexValue: value !== undefined ? indexOf(entry, value) : null,
7665
+ changedAt: stored?.changedAt ?? null,
7666
+ changedBy: stored?.changedBy ?? null
7667
+ };
7668
+ }
7669
+ /** The problems of a value, by field path, `''` for the whole value, as the server checks it. */
7670
+ problemsOf(entry, value) {
7671
+ if (value === null || value === undefined) {
7672
+ return { '': ['required'] };
7673
+ }
7674
+ if (typeof value !== 'object' || Array.isArray(value)
7675
+ || (entry.fields && Object.keys(value).some(field => !entry.fields.includes(field)))) {
7676
+ return { '': ['invalid'] };
7677
+ }
7678
+ if (new TextEncoder().encode(JSON.stringify(value)).length > (entry.maxBytes ?? defaultMaxBytes)) {
7679
+ return { '': ['too-long'] };
7680
+ }
7681
+ const problems = { ...(entry.validate?.(value) ?? {}) };
7682
+ const index = indexOf(entry, value);
7683
+ if (index !== null && index.length > 256) {
7684
+ problems[''] = [...(problems[''] ?? []), 'too-long'];
7685
+ }
7686
+ return Object.fromEntries(Object.entries(problems).filter(([, codes]) => codes.length > 0));
7687
+ }
7688
+ valuesOf(map, agentId) {
7689
+ let values = map.get(agentId);
7690
+ if (!values) {
7691
+ values = new Map();
7692
+ map.set(agentId, values);
7693
+ }
7694
+ return values;
7695
+ }
7696
+ }
7697
+ /** The searchable value of a value, trimmed and in lowercase, as the server stores it. */
7698
+ function indexOf(entry, value) {
7699
+ if (entry.indexBy === undefined) {
7700
+ return null;
7701
+ }
7702
+ const text = value[entry.indexBy];
7703
+ const trimmed = typeof text === 'string' ? text.trim() : '';
7704
+ return trimmed.length > 0 ? trimmed.toLowerCase() : null;
7705
+ }
7706
+
7707
+ /** How long the application may take to report after a hand-off in the mock. */
7708
+ const reportWithinMs = 4 * 60 * 60 * 1000;
5336
7709
  const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
5337
7710
  const maxReason = 500;
5338
7711
  const maxItemsPerPage = 100;
@@ -5357,7 +7730,10 @@ const fourEyesCode = 'assistant-four-eyes-required';
5357
7730
  * answers `404` and deciding it `404 assistant-approval-not-found`. A consultation's proposal that the
5358
7731
  * person who asked decides in their own chat (`asker-decides`) waits inline and is refused here with
5359
7732
  * `assistant-consult-asker-decides`; one that runs with the agent's own rights refuses its asker with
5360
- * `assistant-four-eyes-required`.
7733
+ * `assistant-four-eyes-required`. A proposal of a tool the application executes (`executedByApplication`)
7734
+ * waits as `awaiting-application` once approved, and `POST approvals/{id}/resolve` resolves an
7735
+ * `execution-unknown` proposal as the server does: `hand-to-application` for one the application
7736
+ * executes, `re-execute` for any other, `mark-resolved` for both.
5361
7737
  */
5362
7738
  class NhAssistantMockProposals {
5363
7739
  constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
@@ -5383,6 +7759,9 @@ class NhAssistantMockProposals {
5383
7759
  const route = item.consultationRoute ?? null;
5384
7760
  const refusal = consultationRefusal(route, item.initiatorActorId ?? null, actorId());
5385
7761
  const canDecide = item.canDecide ?? refusal === null;
7762
+ const executedByApplication = item.executedByApplication ?? false;
7763
+ const handedOff = executedByApplication && (item.status === 'awaiting-application' || item.status === 'execution-unknown');
7764
+ const madeAt = item.createdAt ?? new Date(created).toISOString();
5386
7765
  this.proposals.set(item.approvalId, {
5387
7766
  proposal: {
5388
7767
  approvalId: item.approvalId,
@@ -5410,8 +7789,11 @@ class NhAssistantMockProposals {
5410
7789
  initiatorActorId: item.initiatorActorId ?? null,
5411
7790
  decidedByActorId: null,
5412
7791
  decidedAt: null,
5413
- executionResultCode: null,
7792
+ executionResultCode: item.executionResultCode ?? null,
5414
7793
  executedAt: null,
7794
+ executionReportDueAt: handedOff
7795
+ ? item.executionReportDueAt ?? new Date(Date.parse(madeAt) + reportWithinMs).toISOString()
7796
+ : null,
5415
7797
  rationale: item.rationale ?? null,
5416
7798
  placedBy: item.placedBy ?? null,
5417
7799
  parent: item.parent ?? null,
@@ -5421,7 +7803,8 @@ class NhAssistantMockProposals {
5421
7803
  },
5422
7804
  divisionId: item.divisionId ?? null,
5423
7805
  canDecide,
5424
- reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
7806
+ reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied',
7807
+ executedByApplication
5425
7808
  });
5426
7809
  }
5427
7810
  }
@@ -5445,6 +7828,9 @@ class NhAssistantMockProposals {
5445
7828
  if (segments.length === 3 && segments[2] === 'decisions') {
5446
7829
  return method === 'POST' ? this.decideOne(segments[1], body) : { status: 405 };
5447
7830
  }
7831
+ if (segments.length === 3 && segments[2] === 'resolve') {
7832
+ return method === 'POST' ? this.resolve(segments[1], body) : { status: 405 };
7833
+ }
5448
7834
  return null;
5449
7835
  }
5450
7836
  /** The proposals waiting in a thread, newest first; `null` when the scenario lists none there. */
@@ -5616,16 +8002,59 @@ class NhAssistantMockProposals {
5616
8002
  return { code: stored.reasonCode ?? 'assistant-approval-policy-denied' };
5617
8003
  }
5618
8004
  const now = this.now();
8005
+ // A proposal the application executes is handed to it and waits for its report.
8006
+ const handedOff = approve && stored.executedByApplication;
5619
8007
  stored.proposal = {
5620
8008
  ...stored.proposal,
5621
- status: approve ? 'executed' : 'rejected',
8009
+ status: approve ? (handedOff ? 'awaiting-application' : 'executed') : 'rejected',
5622
8010
  decidedByActorId: this.actorId(),
5623
8011
  decidedAt: now,
5624
- executedAt: approve ? now : null
8012
+ executedAt: approve && !handedOff ? now : null,
8013
+ executionReportDueAt: handedOff ? new Date(Date.parse(now) + reportWithinMs).toISOString() : null
5625
8014
  };
5626
8015
  this.decided(stored.proposal);
5627
8016
  return { code: null };
5628
8017
  }
8018
+ /**
8019
+ * `POST approvals/{id}/resolve` as the server answers it: only an `execution-unknown` proposal, which
8020
+ * a proposal the application executes resolves with `hand-to-application` or `mark-resolved` and
8021
+ * every other one with `re-execute` or `mark-resolved`. Handed to the application again, it waits
8022
+ * for its report once more; run again, it executes at once.
8023
+ */
8024
+ resolve(approvalId, body) {
8025
+ const request = body;
8026
+ const action = request?.action;
8027
+ if (action !== 're-execute' && action !== 'hand-to-application' && action !== 'mark-resolved') {
8028
+ return mockFailure('assistant-approval-decision-invalid');
8029
+ }
8030
+ const stored = this.find(approvalId);
8031
+ if (!stored) {
8032
+ return mockFailure('assistant-approval-not-found');
8033
+ }
8034
+ if (stored.proposal.status !== 'execution-unknown') {
8035
+ return mockFailure('assistant-execution-not-uncertain');
8036
+ }
8037
+ if (!stored.canDecide) {
8038
+ return mockFailure(stored.reasonCode ?? 'assistant-approval-policy-denied');
8039
+ }
8040
+ const handedOff = !!stored.proposal.executionReportDueAt;
8041
+ if ((action === 'hand-to-application' && !handedOff) || (action === 're-execute' && handedOff)) {
8042
+ return mockFailure('assistant-approval-decision-invalid');
8043
+ }
8044
+ const now = this.now();
8045
+ stored.proposal = {
8046
+ ...stored.proposal,
8047
+ status: action === 'mark-resolved' ? 'resolved' : action === 'hand-to-application' ? 'awaiting-application' : 'executed',
8048
+ decidedByActorId: this.actorId(),
8049
+ executedAt: action === 're-execute' ? now : stored.proposal.executedAt,
8050
+ executionResultCode: action === 'mark-resolved' ? 'resolved-by-approver' : null,
8051
+ executionReportDueAt: action === 'hand-to-application'
8052
+ ? new Date(Date.parse(now) + reportWithinMs).toISOString()
8053
+ : stored.proposal.executionReportDueAt
8054
+ };
8055
+ this.decided(stored.proposal);
8056
+ return { status: 200, body: stored.proposal };
8057
+ }
5629
8058
  withRight(item) {
5630
8059
  const pending = item.proposal.status === 'pending';
5631
8060
  return {
@@ -5822,6 +8251,25 @@ class NhAssistantMockBackend {
5822
8251
  agents: () => this.admin.allAgents(),
5823
8252
  administers: () => this.admin.administers
5824
8253
  }, () => new Date().toISOString());
8254
+ this.agentMetadata = new NhAssistantMockAgentMetadata(this.scenario, {
8255
+ agent: agentId => this.admin.findAgent(agentId),
8256
+ administers: () => this.admin.administers,
8257
+ nextVersion: agentId => this.admin.nextAgentVersion(agentId)
8258
+ }, () => new Date().toISOString());
8259
+ /** Agent packages: their endpoints, and the locks and links of every other administration request. */
8260
+ this.agentPackages = new NhAssistantMockAgentPackages({
8261
+ call: (method, path, body) => this.adminCall(method, path, body),
8262
+ codeDefinition: agentId => this.admin.codeDefinition(agentId),
8263
+ snapshot: () => {
8264
+ const restores = [this.admin.snapshot(), this.agentMetadata.snapshot(), this.boards.snapshot()];
8265
+ return () => {
8266
+ for (const restore of restores) {
8267
+ restore();
8268
+ }
8269
+ };
8270
+ },
8271
+ now: () => new Date().toISOString()
8272
+ });
5825
8273
  this.canAdministerState = signal(this.admin.administers, ...(ngDevMode ? [{ debugName: "canAdministerState" }] : []));
5826
8274
  this.members = new Map();
5827
8275
  this.readPositions = new Map();
@@ -5896,6 +8344,8 @@ class NhAssistantMockBackend {
5896
8344
  this.conversations.set(copy.id, copy);
5897
8345
  this.readPositions.set(copy.id, lastSequence(copy));
5898
8346
  }
8347
+ // The packages the host imported before, once its agents and run configurations are in place.
8348
+ this.agentPackages.importPresets(this.scenario.admin?.agentPackages ?? []);
5899
8349
  }
5900
8350
  /**
5901
8351
  * Plays a scripted turn as another person in the conversation, as if a colleague with an
@@ -5990,6 +8440,17 @@ class NhAssistantMockBackend {
5990
8440
  setMcpConnectionStatus(serverId, status) {
5991
8441
  this.admin.setMcpConnectionStatus(serverId, status);
5992
8442
  }
8443
+ /**
8444
+ * Publishes or changes one version of a text asset of the application. A skill or agent that names
8445
+ * this version with another hash then reports `asset-changed`, as after the server's next load.
8446
+ */
8447
+ setInstructionAsset(asset) {
8448
+ this.admin.instructionAssets.setAsset(asset);
8449
+ }
8450
+ /** Removes one version of a text asset; what names it reports `asset-missing`. */
8451
+ removeInstructionAsset(assetId, version) {
8452
+ this.admin.instructionAssets.removeAsset(assetId, version);
8453
+ }
5993
8454
  /** The files people uploaded, oldest first, as the application's file source stored them. */
5994
8455
  get uploads() {
5995
8456
  return this.files.uploads;
@@ -6032,23 +8493,15 @@ class NhAssistantMockBackend {
6032
8493
  if (segments[0] === 'admin' && segments[1] === 'agents' && segments[3] === 'runs' && segments.length === 4) {
6033
8494
  return this.result(this.agentRuns.handleAgentRuns(method, segments[2], query, language));
6034
8495
  }
6035
- if (segments[0] === 'admin' && segments[1] === 'agents' && segments[3] === 'groups' && segments.length === 4) {
6036
- return this.result(this.boards.handleAdminGroups(method, segments[2], body));
6037
- }
6038
8496
  if (segments[0] === 'admin') {
6039
- const result = this.admin.handleAdmin(method, segments, body, query);
6040
- if (method === 'DELETE' && segments[1] === 'agents' && segments.length === 3 && result.status === 204) {
6041
- this.boards.forget(segments[2]);
6042
- }
6043
- // The run catalog lists the subject types and the groups in the request's language, as the
6044
- // server does; agents carry their groups.
6045
- if (method === 'GET' && segments.length === 2 && segments[1] === 'run-catalog' && result.status === 200) {
6046
- return this.result({
6047
- ...result,
6048
- body: { ...result.body, subjectTypes: this.subjects.catalog(language), agentGroups: this.boards.catalog(language) }
6049
- });
8497
+ // Agent packages have endpoints of their own; every other save runs under their locks, and
8498
+ // every read of the administration carries the links of the parts.
8499
+ const packaged = this.agentPackages.handle(method, segments, body);
8500
+ if (packaged) {
8501
+ return this.result(packaged);
6050
8502
  }
6051
- return this.result(segments[1] === 'agents' && result.status >= 200 && result.status < 300 ? this.withGroups(result) : result);
8503
+ const result = this.agentPackages.guard(method, segments, () => this.adminResult(method, segments, query, body, language));
8504
+ return this.result(this.agentPackages.decorate(method, segments, result));
6052
8505
  }
6053
8506
  if (segments[0] === 'boards') {
6054
8507
  return this.result(this.boards.handle(method, segments, query, language) ?? { status: 404 });
@@ -6690,6 +9143,44 @@ class NhAssistantMockBackend {
6690
9143
  const suffix = this.sequence.toString(16).padStart(12, '0');
6691
9144
  return `00000000-0000-4000-8000-${suffix}`;
6692
9145
  }
9146
+ /**
9147
+ * The answer of an administration endpoint (`admin/...`) as its area gives it, with the groups of
9148
+ * agents and the run catalog in the request's language, without the package locks and links.
9149
+ */
9150
+ adminResult(method, segments, query, body, language) {
9151
+ if (segments[1] === 'agents' && segments[3] === 'groups' && segments.length === 4) {
9152
+ return this.boards.handleAdminGroups(method, segments[2], body);
9153
+ }
9154
+ if (segments[1] === 'agents' && segments[3] === 'metadata' && segments.length <= 5) {
9155
+ return this.agentMetadata.handle(method, segments[2], segments[4], query, body);
9156
+ }
9157
+ const result = this.admin.handleAdmin(method, segments, body, query);
9158
+ if (method === 'DELETE' && segments[1] === 'agents' && segments.length === 3 && result.status === 204) {
9159
+ this.boards.forget(segments[2]);
9160
+ this.agentMetadata.forget(segments[2]);
9161
+ this.agentPackages.forget(segments[2]);
9162
+ }
9163
+ // The run catalog lists the subject types and the groups in the request's language, as the
9164
+ // server does; agents carry their groups.
9165
+ if (method === 'GET' && segments.length === 2 && segments[1] === 'run-catalog' && result.status === 200) {
9166
+ return {
9167
+ ...result,
9168
+ body: {
9169
+ ...result.body,
9170
+ subjectTypes: this.subjects.catalog(language),
9171
+ agentGroups: this.boards.catalog(language),
9172
+ agentMetadata: this.agentMetadata.catalog()
9173
+ }
9174
+ };
9175
+ }
9176
+ return segments[1] === 'agents' && result.status >= 200 && result.status < 300 ? this.withGroups(result) : result;
9177
+ }
9178
+ /** An administration request of the agent packages, as `adminResult` answers it, for a path with its query. */
9179
+ adminCall(method, path, body) {
9180
+ const url = new URL(path, 'http://mock.local/');
9181
+ const segments = url.pathname.slice(1).split('/').map(segment => decodeURIComponent(segment));
9182
+ return this.adminResult(method, segments, url.searchParams, body, null);
9183
+ }
6693
9184
  /** An administration answer about agents with each agent's groups, as the server answers it. */
6694
9185
  withGroups(result) {
6695
9186
  const enrich = (item) => {
@@ -6705,6 +9196,9 @@ class NhAssistantMockBackend {
6705
9196
  return { groups: membership.groups, codeGroups: membership.codeGroups };
6706
9197
  }
6707
9198
  result(result) {
9199
+ if (result.text !== undefined) {
9200
+ return new Response(result.text, { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
9201
+ }
6708
9202
  return result.body === undefined ? this.empty(result.status) : this.json(result.status, result.body);
6709
9203
  }
6710
9204
  json(status, value) {