@newheap/platform-ai-chat 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/admin/index.d.ts +320 -11
- package/agent-runs/index.d.ts +5 -0
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +5792 -3527
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs +9 -2
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +2989 -139
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +2237 -90
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +470 -10
- package/i18n/nl.json +470 -10
- package/index.d.ts +1272 -26
- package/package.json +1 -1
- package/push/nh-assistant-push-worker.js +13 -6
- package/testing/index.d.ts +199 -5
|
@@ -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, applyNhAssistantQuestionAnswer, 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
|
/**
|
|
@@ -25,6 +25,8 @@ const statusTable = {
|
|
|
25
25
|
'assistant-run-filter-invalid',
|
|
26
26
|
'assistant-board-scope-invalid',
|
|
27
27
|
'assistant-feedback-invalid',
|
|
28
|
+
'assistant-question-invalid',
|
|
29
|
+
'assistant-question-answer-invalid',
|
|
28
30
|
'assistant-trigger-filter-not-found',
|
|
29
31
|
'assistant-replay-not-shadow',
|
|
30
32
|
'assistant-evaluation-not-variant',
|
|
@@ -55,6 +57,7 @@ const statusTable = {
|
|
|
55
57
|
'assistant-context-unavailable',
|
|
56
58
|
'assistant-owner-required',
|
|
57
59
|
'assistant-approval-forbidden',
|
|
60
|
+
'assistant-question-forbidden',
|
|
58
61
|
'assistant-turn-forbidden',
|
|
59
62
|
'assistant-subject-access-denied',
|
|
60
63
|
'assistant-approval-policy-denied',
|
|
@@ -68,7 +71,10 @@ const statusTable = {
|
|
|
68
71
|
'assistant-disabled',
|
|
69
72
|
'assistant-conversation-not-found',
|
|
70
73
|
'assistant-agent-not-found',
|
|
74
|
+
'assistant-metadata-entry-not-found',
|
|
75
|
+
'assistant-instructions-asset-not-found',
|
|
71
76
|
'assistant-approval-not-found',
|
|
77
|
+
'assistant-question-not-found',
|
|
72
78
|
'assistant-share-link-invalid',
|
|
73
79
|
'assistant-participant-not-found',
|
|
74
80
|
'assistant-directory-unavailable',
|
|
@@ -84,6 +90,8 @@ const statusTable = {
|
|
|
84
90
|
'assistant-artifact-not-found',
|
|
85
91
|
'assistant-trigger-not-found',
|
|
86
92
|
'assistant-consultation-not-found',
|
|
93
|
+
'assistant-handoff-not-found',
|
|
94
|
+
'assistant-package-not-linked',
|
|
87
95
|
'assistant-not-found',
|
|
88
96
|
'assistant-context-not-found',
|
|
89
97
|
'assistant-mcp-server-not-found',
|
|
@@ -107,6 +115,7 @@ const statusTable = {
|
|
|
107
115
|
'assistant-agent-does-not-work-on',
|
|
108
116
|
'assistant-moved-to-shadow',
|
|
109
117
|
'assistant-shadow-variant',
|
|
118
|
+
'assistant-not-shadow-variant',
|
|
110
119
|
'assistant-service-account-not-used',
|
|
111
120
|
'assistant-consult-route-not-allowed',
|
|
112
121
|
'assistant-consult-proposal-refused',
|
|
@@ -114,6 +123,9 @@ const statusTable = {
|
|
|
114
123
|
'assistant-consult-proposal-moved',
|
|
115
124
|
'assistant-consultation-in-code',
|
|
116
125
|
'assistant-consultations-not-allowed',
|
|
126
|
+
'assistant-handoffs-not-allowed',
|
|
127
|
+
'assistant-handoff-in-code',
|
|
128
|
+
'assistant-shadow-variants-not-allowed',
|
|
117
129
|
'assistant-skill-in-code',
|
|
118
130
|
'assistant-trigger-in-code',
|
|
119
131
|
'assistant-skill-in-use',
|
|
@@ -126,11 +138,18 @@ const statusTable = {
|
|
|
126
138
|
'assistant-memory-conflict',
|
|
127
139
|
'assistant-replay-active',
|
|
128
140
|
'assistant-feedback-not-accepted',
|
|
141
|
+
'assistant-question-not-pending',
|
|
142
|
+
'assistant-question-expired',
|
|
143
|
+
'assistant-question-cancelled',
|
|
129
144
|
'assistant-trigger-rate-limited',
|
|
130
145
|
'assistant-version-conflict',
|
|
131
146
|
'assistant-agent-exists',
|
|
132
147
|
'assistant-code-agent-not-deletable',
|
|
133
148
|
'assistant-agent-not-code',
|
|
149
|
+
'assistant-metadata-read-only',
|
|
150
|
+
'assistant-package-refused',
|
|
151
|
+
'assistant-package-locked',
|
|
152
|
+
'assistant-agent-instructions-unavailable',
|
|
134
153
|
'assistant-mcp-server-exists',
|
|
135
154
|
'assistant-mcp-headless-not-allowed',
|
|
136
155
|
'assistant-mcp-consent-required',
|
|
@@ -144,6 +163,7 @@ const statusTable = {
|
|
|
144
163
|
],
|
|
145
164
|
413: [
|
|
146
165
|
'assistant-webhook-payload-too-large',
|
|
166
|
+
'assistant-package-too-large',
|
|
147
167
|
'assistant-file-too-large'
|
|
148
168
|
],
|
|
149
169
|
415: [
|
|
@@ -169,6 +189,7 @@ const statusTable = {
|
|
|
169
189
|
'assistant-proposal-check-failed',
|
|
170
190
|
'assistant-trigger-rolled-back',
|
|
171
191
|
'assistant-mcp-secret-unavailable',
|
|
192
|
+
'assistant-instructions-asset-unavailable',
|
|
172
193
|
'assistant-file-unavailable',
|
|
173
194
|
'assistant-file-upload-failed'
|
|
174
195
|
]
|
|
@@ -215,6 +236,22 @@ function mockFailure(code, errors) {
|
|
|
215
236
|
function mockValidationFailure(field) {
|
|
216
237
|
return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
|
|
217
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* Copies the stored state of a mock area: the returned function puts every map back as it was, so
|
|
241
|
+
* an agent package import that one part refuses leaves nothing behind, as the server's one
|
|
242
|
+
* transaction does, and a preview tries the import without keeping it.
|
|
243
|
+
*/
|
|
244
|
+
function mockSnapshot(...maps) {
|
|
245
|
+
const copies = maps.map(map => structuredClone(map));
|
|
246
|
+
return () => {
|
|
247
|
+
maps.forEach((map, index) => {
|
|
248
|
+
map.clear();
|
|
249
|
+
for (const [key, value] of copies[index]) {
|
|
250
|
+
map.set(key, value);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
}
|
|
218
255
|
/** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
|
|
219
256
|
/**
|
|
220
257
|
* The warnings of an agent or skill that classifies its model calls with `classification` on
|
|
@@ -397,8 +434,9 @@ class NhAssistantMockAdminAgents {
|
|
|
397
434
|
const agents = admin.agents
|
|
398
435
|
?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
|
|
399
436
|
// 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
|
|
437
|
+
for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
|
|
438
|
+
const variant = typeof shadowOf === 'string';
|
|
439
|
+
const agentShadow = variant || shadow === true;
|
|
402
440
|
const initial = this.toAgent(input, source, 1, false);
|
|
403
441
|
this.agents.set(input.id, {
|
|
404
442
|
agent: {
|
|
@@ -407,7 +445,7 @@ class NhAssistantMockAdminAgents {
|
|
|
407
445
|
...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
|
|
408
446
|
knownSubjectTypes: knownSubjectTypes ?? [],
|
|
409
447
|
shadowOf: shadowOf ?? null,
|
|
410
|
-
shadow: shadowState(agentShadow,
|
|
448
|
+
shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
|
|
411
449
|
serviceAccount: serviceAccount === undefined
|
|
412
450
|
? null
|
|
413
451
|
: { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
|
|
@@ -425,7 +463,7 @@ class NhAssistantMockAdminAgents {
|
|
|
425
463
|
dailyTokens: null,
|
|
426
464
|
cancelledRuns: 0,
|
|
427
465
|
startedRuns: 0,
|
|
428
|
-
shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null,
|
|
466
|
+
shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
|
|
429
467
|
}))
|
|
430
468
|
},
|
|
431
469
|
codeDefinition: source === 'code' ? structuredClone(input) : null,
|
|
@@ -439,6 +477,18 @@ class NhAssistantMockAdminAgents {
|
|
|
439
477
|
find(agentId) {
|
|
440
478
|
return this.agents.get(agentId)?.agent;
|
|
441
479
|
}
|
|
480
|
+
/**
|
|
481
|
+
* A copy of the agents and their replays; the returned function puts it back, for an import of an
|
|
482
|
+
* agent package that fails as a whole and for its preview.
|
|
483
|
+
*/
|
|
484
|
+
snapshot() {
|
|
485
|
+
return mockSnapshot(this.agents, this.replays);
|
|
486
|
+
}
|
|
487
|
+
/** The definition in code of an agent in code, as `reset` restores it; `null` for an agent created here. */
|
|
488
|
+
codeDefinition(agentId) {
|
|
489
|
+
const definition = this.agents.get(agentId)?.codeDefinition;
|
|
490
|
+
return definition ? structuredClone(definition) : null;
|
|
491
|
+
}
|
|
442
492
|
has(agentId) {
|
|
443
493
|
return this.agents.has(agentId);
|
|
444
494
|
}
|
|
@@ -446,6 +496,53 @@ class NhAssistantMockAdminAgents {
|
|
|
446
496
|
all() {
|
|
447
497
|
return [...this.agents.values()].map(stored => stored.agent);
|
|
448
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Makes the next version of an agent, as a change of the metadata of an agent created here does on
|
|
501
|
+
* the server; nothing else of the agent changes.
|
|
502
|
+
*/
|
|
503
|
+
nextVersion(agentId) {
|
|
504
|
+
const stored = this.agents.get(agentId);
|
|
505
|
+
if (!stored) {
|
|
506
|
+
throw new Error(`The mock has no agent '${agentId}'.`);
|
|
507
|
+
}
|
|
508
|
+
stored.agent = { ...stored.agent, version: stored.agent.version + 1, updatedAt: this.now() };
|
|
509
|
+
return stored.agent;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Copies a data shadow variant's definition onto its live agent as the live agent's next version,
|
|
513
|
+
* as a promotion does on the server before it deletes the variant: the live agent keeps its name,
|
|
514
|
+
* MCP servers, state and shadow mode; an agent in code takes only the fields it can override, an
|
|
515
|
+
* agent created here also the variant's profile, classification, what it works on and its asset.
|
|
516
|
+
*/
|
|
517
|
+
promote(variantId, liveId) {
|
|
518
|
+
const variant = this.agents.get(variantId);
|
|
519
|
+
const live = this.agents.get(liveId);
|
|
520
|
+
if (!variant || !live) {
|
|
521
|
+
throw new Error(`The mock has no agent '${variant ? liveId : variantId}'.`);
|
|
522
|
+
}
|
|
523
|
+
const from = variant.agent;
|
|
524
|
+
const runtime = live.agent.source === 'admin';
|
|
525
|
+
const definition = {
|
|
526
|
+
id: live.agent.id,
|
|
527
|
+
displayName: live.agent.displayName,
|
|
528
|
+
description: from.description,
|
|
529
|
+
instructions: runtime && from.instructionsAsset ? '' : from.instructions,
|
|
530
|
+
toolSelectors: [...from.toolSelectors],
|
|
531
|
+
mcpServerIds: [...live.agent.mcpServerIds],
|
|
532
|
+
requiredPolicy: from.requiredPolicy,
|
|
533
|
+
autonomy: from.autonomy,
|
|
534
|
+
...(runtime
|
|
535
|
+
? {
|
|
536
|
+
...pickWorksOn(from),
|
|
537
|
+
profile: from.profile,
|
|
538
|
+
dataClassification: from.dataClassification ?? null,
|
|
539
|
+
instructionsAsset: from.instructionsAsset ?? null
|
|
540
|
+
}
|
|
541
|
+
: {})
|
|
542
|
+
};
|
|
543
|
+
live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
|
|
544
|
+
return live.agent;
|
|
545
|
+
}
|
|
449
546
|
/** Removes a deleted MCP server from the agents it was assigned to. */
|
|
450
547
|
removeServer(serverId) {
|
|
451
548
|
for (const agent of this.agents.values()) {
|
|
@@ -480,7 +577,7 @@ class NhAssistantMockAdminAgents {
|
|
|
480
577
|
agentsEndpoint(method, id, action, body, length) {
|
|
481
578
|
if (id === undefined) {
|
|
482
579
|
if (method === 'GET') {
|
|
483
|
-
return { status: 200, body: [...this.agents.values()].map(stored => stored.agent) };
|
|
580
|
+
return { status: 200, body: [...this.agents.values()].map(stored => this.withInstructions(stored.agent)) };
|
|
484
581
|
}
|
|
485
582
|
if (method !== 'POST') {
|
|
486
583
|
return { status: 405 };
|
|
@@ -493,7 +590,21 @@ class NhAssistantMockAdminAgents {
|
|
|
493
590
|
if (this.agents.has(input.id)) {
|
|
494
591
|
return mockFailure('assistant-agent-exists');
|
|
495
592
|
}
|
|
496
|
-
const
|
|
593
|
+
const shadowOf = typeof input.shadowOf === 'string' && input.shadowOf.trim().length > 0 ? input.shadowOf.trim() : null;
|
|
594
|
+
// Without the application's ceiling the mock creates no data variants, as the server refuses them.
|
|
595
|
+
const refused = shadowOf === null
|
|
596
|
+
? null
|
|
597
|
+
: this.rules.shadowVariantRefusal
|
|
598
|
+
? this.rules.shadowVariantRefusal({ ...input, shadowOf })
|
|
599
|
+
: mockFailure('assistant-shadow-variants-not-allowed');
|
|
600
|
+
if (refused) {
|
|
601
|
+
return refused;
|
|
602
|
+
}
|
|
603
|
+
const created = this.toAgent(input, 'admin', 1, false);
|
|
604
|
+
// A data shadow variant always works in shadow and is never offered in the chat.
|
|
605
|
+
const agent = shadowOf === null
|
|
606
|
+
? created
|
|
607
|
+
: { ...created, shadowOf, offeredInChat: false, shadow: shadowState(true, false, null, true) };
|
|
497
608
|
this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
|
|
498
609
|
return { status: 201, body: agent };
|
|
499
610
|
}
|
|
@@ -549,7 +660,9 @@ class NhAssistantMockAdminAgents {
|
|
|
549
660
|
return mockFailure('assistant-version-conflict');
|
|
550
661
|
}
|
|
551
662
|
const { expectedVersion, ...definition } = input;
|
|
552
|
-
|
|
663
|
+
const updated = this.toAgent(definition, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
|
|
664
|
+
// A data shadow variant keeps its live agent and stays out of the chat.
|
|
665
|
+
stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
|
|
553
666
|
return { status: 200, body: stored.agent };
|
|
554
667
|
}
|
|
555
668
|
if (method === 'DELETE') {
|
|
@@ -966,6 +1079,22 @@ class NhAssistantMockAdminAgents {
|
|
|
966
1079
|
if (typeof input?.instructions === 'string' && input.instructions.length > mockMaxInstructions) {
|
|
967
1080
|
return mockFailure('assistant-instructions-too-long');
|
|
968
1081
|
}
|
|
1082
|
+
// Only an agent created here takes its instructions from an asset, never next to inline text.
|
|
1083
|
+
if (input?.instructionsAsset) {
|
|
1084
|
+
const errors = {};
|
|
1085
|
+
if (source !== 'admin' || !this.rules.instructionAssets) {
|
|
1086
|
+
errors['instructionsAsset'] = ['not-allowed'];
|
|
1087
|
+
}
|
|
1088
|
+
else {
|
|
1089
|
+
this.rules.instructionAssets().check(input.instructionsAsset, errors);
|
|
1090
|
+
}
|
|
1091
|
+
if (typeof input.instructions === 'string' && input.instructions.trim().length > 0) {
|
|
1092
|
+
errors['instructions'] = ['not-allowed'];
|
|
1093
|
+
}
|
|
1094
|
+
if (Object.keys(errors).length > 0) {
|
|
1095
|
+
return mockFailure('assistant-validation', errors);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
969
1098
|
if (!this.validAgent(input)) {
|
|
970
1099
|
return mockValidationFailure();
|
|
971
1100
|
}
|
|
@@ -1010,8 +1139,14 @@ class NhAssistantMockAdminAgents {
|
|
|
1010
1139
|
*/
|
|
1011
1140
|
toAgent(input, source, version, isOverridden, previous) {
|
|
1012
1141
|
// Only a new agent starts off, created that way here or with StartsOff() in code.
|
|
1013
|
-
|
|
1142
|
+
// Metadata is not part of the definition; its values live with the mock's agent metadata.
|
|
1143
|
+
const { isEnabled, startsOff, startsInShadow, profile, dataClassification, metadata, instructionsAsset, ...definition } = structuredClone(input);
|
|
1014
1144
|
const createdOff = previous === undefined && startsOff === true;
|
|
1145
|
+
// Only a new agent created here starts in shadow, as an override that applies once it has run settings.
|
|
1146
|
+
const createdInShadow = previous === undefined && source === 'admin' && startsInShadow === true;
|
|
1147
|
+
// The agent shows the asset's text read-only, or none while it does not resolve.
|
|
1148
|
+
const reference = source === 'admin' && instructionsAsset ? instructionsAsset : null;
|
|
1149
|
+
const assetProblem = reference ? this.instructionsProblemOf(reference) : null;
|
|
1015
1150
|
const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
|
|
1016
1151
|
const classification = chosenClassification(dataClassification);
|
|
1017
1152
|
return this.withWarnings({
|
|
@@ -1026,7 +1161,10 @@ class NhAssistantMockAdminAgents {
|
|
|
1026
1161
|
version,
|
|
1027
1162
|
source,
|
|
1028
1163
|
isOverridden,
|
|
1029
|
-
|
|
1164
|
+
instructions: reference ? this.rules.instructionAssets?.().textOf(reference) ?? '' : definition.instructions,
|
|
1165
|
+
instructionsHash: reference ? reference.hash : mockHashText(input.instructions),
|
|
1166
|
+
instructionsAsset: reference,
|
|
1167
|
+
instructionsProblem: assetProblem,
|
|
1030
1168
|
updatedAt: this.now(),
|
|
1031
1169
|
runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
|
|
1032
1170
|
stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
|
|
@@ -1035,7 +1173,9 @@ class NhAssistantMockAdminAgents {
|
|
|
1035
1173
|
waitingInputs: previous?.waitingInputs ?? 0,
|
|
1036
1174
|
skills: previous?.skills ?? [],
|
|
1037
1175
|
shadowOf: previous?.shadowOf ?? null,
|
|
1038
|
-
shadow: previous?.shadow ??
|
|
1176
|
+
shadow: previous?.shadow ?? (createdInShadow
|
|
1177
|
+
? { ...shadowState(true, false, true, false), changedAt: this.now(), changedBy: 'current-user' }
|
|
1178
|
+
: shadowState(false, false, null, false)),
|
|
1039
1179
|
serviceAccount: previous?.serviceAccount ?? null,
|
|
1040
1180
|
offBeforeStop: previous?.offBeforeStop ?? false,
|
|
1041
1181
|
// Code decides for its agents; an agent created here keeps its choice when it is omitted.
|
|
@@ -1049,6 +1189,26 @@ class NhAssistantMockAdminAgents {
|
|
|
1049
1189
|
knownSubjectTypes: previous?.knownSubjectTypes ?? []
|
|
1050
1190
|
});
|
|
1051
1191
|
}
|
|
1192
|
+
/** Why the asset an agent created here names does not resolve now, or `null` when it does. */
|
|
1193
|
+
instructionsProblemOf(reference) {
|
|
1194
|
+
const assets = this.rules.instructionAssets?.();
|
|
1195
|
+
return assets ? assets.problemOf(reference) : 'no-asset-source';
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* The agent as a read finds it now: the text of the asset it names and the problem when that no
|
|
1199
|
+
* longer resolves, as the server checks it at every load.
|
|
1200
|
+
*/
|
|
1201
|
+
withInstructions(agent) {
|
|
1202
|
+
if (!agent.instructionsAsset) {
|
|
1203
|
+
return agent;
|
|
1204
|
+
}
|
|
1205
|
+
const problem = this.instructionsProblemOf(agent.instructionsAsset);
|
|
1206
|
+
return {
|
|
1207
|
+
...agent,
|
|
1208
|
+
instructions: problem === null ? this.rules.instructionAssets?.().textOf(agent.instructionsAsset) ?? '' : '',
|
|
1209
|
+
instructionsProblem: problem
|
|
1210
|
+
};
|
|
1211
|
+
}
|
|
1052
1212
|
/** The agent with the warnings the server reports for it, from its profile and classification. */
|
|
1053
1213
|
withWarnings(agent) {
|
|
1054
1214
|
return {
|
|
@@ -2335,6 +2495,325 @@ function kebab(value) {
|
|
|
2335
2495
|
.toLowerCase();
|
|
2336
2496
|
}
|
|
2337
2497
|
|
|
2498
|
+
const maxHandOffs = 20;
|
|
2499
|
+
const maxSources = 20;
|
|
2500
|
+
const maxDescription$3 = 500;
|
|
2501
|
+
const maxPerDay = 100;
|
|
2502
|
+
const defaultPerDay = 5;
|
|
2503
|
+
const defaultVariants = 3;
|
|
2504
|
+
/**
|
|
2505
|
+
* In-memory hand-offs defined as data and shadow variants of the mock API (S9), within the scenario's
|
|
2506
|
+
* `handOffsAsData` and `shadowVariantsAsData` as the ceilings in code: saving checks a hand-off as the
|
|
2507
|
+
* server checks it on every save and load, and `GET` reports a stored hand-off that no longer fits,
|
|
2508
|
+
* for example after its source lost its run settings, out of effect with its problem. A data variant
|
|
2509
|
+
* is created with `POST admin/agents` and `shadowOf`, which `shadowVariantRefusal` checks.
|
|
2510
|
+
*/
|
|
2511
|
+
class NhAssistantMockHandOffs {
|
|
2512
|
+
constructor(scenario, agents, now) {
|
|
2513
|
+
this.scenario = scenario;
|
|
2514
|
+
this.agents = agents;
|
|
2515
|
+
this.now = now;
|
|
2516
|
+
this.stored = new Map();
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* A copy of the hand-offs defined as data; the returned function puts it back, for an import of
|
|
2520
|
+
* an agent package that fails as a whole and for its preview.
|
|
2521
|
+
*/
|
|
2522
|
+
snapshot() {
|
|
2523
|
+
return mockSnapshot(this.stored);
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* `GET admin/agents/{id}/hand-offs`, `PUT` and `DELETE admin/agents/{id}/hand-offs/{subjectType}`
|
|
2527
|
+
* and `GET admin/agents/{id}/shadow-variants`; `segments` start after the agent id. `null` for
|
|
2528
|
+
* another route.
|
|
2529
|
+
*/
|
|
2530
|
+
handle(method, agentId, segments, body, query) {
|
|
2531
|
+
const [area, subjectType] = segments;
|
|
2532
|
+
if (area === 'shadow-variants' && segments.length === 1) {
|
|
2533
|
+
const agent = this.agents.find(agentId);
|
|
2534
|
+
if (!agent) {
|
|
2535
|
+
return mockFailure('assistant-agent-not-found');
|
|
2536
|
+
}
|
|
2537
|
+
return method === 'GET' ? { status: 200, body: this.variantsOf(agent) } : { status: 405 };
|
|
2538
|
+
}
|
|
2539
|
+
if (area !== 'hand-offs' || segments.length > 2) {
|
|
2540
|
+
return null;
|
|
2541
|
+
}
|
|
2542
|
+
const agent = this.agents.find(agentId);
|
|
2543
|
+
if (!agent) {
|
|
2544
|
+
return mockFailure('assistant-agent-not-found');
|
|
2545
|
+
}
|
|
2546
|
+
if (subjectType === undefined) {
|
|
2547
|
+
return method === 'GET' ? { status: 200, body: this.handOffsOf(agent) } : { status: 405 };
|
|
2548
|
+
}
|
|
2549
|
+
if (method === 'PUT') {
|
|
2550
|
+
return this.save(agent, subjectType, body);
|
|
2551
|
+
}
|
|
2552
|
+
if (method === 'DELETE') {
|
|
2553
|
+
return this.remove(agent, subjectType, query);
|
|
2554
|
+
}
|
|
2555
|
+
return { status: 405 };
|
|
2556
|
+
}
|
|
2557
|
+
/** Whether the agent accepts a hand-off defined as data, which keeps its run settings in use. */
|
|
2558
|
+
acceptsDataHandOffs(agentId) {
|
|
2559
|
+
return [...this.stored.values()].some(handOff => handOff.agentId === agentId);
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Why `input` may not create a data shadow variant, as `POST admin/agents` with `shadowOf` answers,
|
|
2563
|
+
* or `null`.
|
|
2564
|
+
*/
|
|
2565
|
+
shadowVariantRefusal(input) {
|
|
2566
|
+
const liveId = input.shadowOf ?? null;
|
|
2567
|
+
if (liveId === null) {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
if (liveId === input.id) {
|
|
2571
|
+
return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
|
|
2572
|
+
}
|
|
2573
|
+
const live = this.agents.find(liveId);
|
|
2574
|
+
if (!this.allowsVariantsOf(liveId, live)) {
|
|
2575
|
+
return mockFailure('assistant-shadow-variants-not-allowed');
|
|
2576
|
+
}
|
|
2577
|
+
if (!live || !this.agents.isConfigured(live)) {
|
|
2578
|
+
return mockFailure('assistant-validation', { shadowOf: ['not-found'] });
|
|
2579
|
+
}
|
|
2580
|
+
if (!isLive(live)) {
|
|
2581
|
+
return mockFailure('assistant-validation', { shadowOf: ['invalid'] });
|
|
2582
|
+
}
|
|
2583
|
+
return this.dataVariantsOf(liveId).length >= this.maxVariants()
|
|
2584
|
+
? mockFailure('assistant-configuration-limit')
|
|
2585
|
+
: null;
|
|
2586
|
+
}
|
|
2587
|
+
handOffsOf(agent) {
|
|
2588
|
+
return {
|
|
2589
|
+
agentId: agent.id,
|
|
2590
|
+
codeHandOffs: this.codeHandOffsOf(agent.id),
|
|
2591
|
+
dataHandOffs: [...this.stored.values()]
|
|
2592
|
+
.filter(item => item.agentId === agent.id)
|
|
2593
|
+
.sort((first, second) => first.subjectType.localeCompare(second.subjectType))
|
|
2594
|
+
.map(({ agentId, ...item }) => {
|
|
2595
|
+
const problem = this.problemOf(agent, item.subjectType, item.sources, item.maxPerSubjectPerDay);
|
|
2596
|
+
return { ...item, inEffect: problem === null, problem };
|
|
2597
|
+
}),
|
|
2598
|
+
ceiling: this.ceilingOf(agent),
|
|
2599
|
+
configured: this.agents.isConfigured(agent)
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
save(agent, subjectType, request) {
|
|
2603
|
+
const ceiling = this.ceilingOf(agent);
|
|
2604
|
+
if (!ceiling) {
|
|
2605
|
+
return mockFailure('assistant-handoffs-not-allowed');
|
|
2606
|
+
}
|
|
2607
|
+
if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
|
|
2608
|
+
return mockFailure('assistant-handoff-in-code');
|
|
2609
|
+
}
|
|
2610
|
+
if (!this.agents.isConfigured(agent)) {
|
|
2611
|
+
return mockFailure('assistant-agent-not-configured');
|
|
2612
|
+
}
|
|
2613
|
+
const sources = Array.isArray(request?.sources) ? request.sources.map(source => String(source).trim()) : [];
|
|
2614
|
+
const description = typeof request?.description === 'string' && request.description.trim().length > 0 ? request.description.trim() : null;
|
|
2615
|
+
const limit = request?.maxPerSubjectPerDay ?? Math.min(defaultPerDay, ceiling.maxPerSubjectPerDay);
|
|
2616
|
+
const reason = typeof request?.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
2617
|
+
const errors = {};
|
|
2618
|
+
if (sources.length === 0) {
|
|
2619
|
+
errors['sources'] = ['required'];
|
|
2620
|
+
}
|
|
2621
|
+
else if (sources.length > maxSources) {
|
|
2622
|
+
errors['sources'] = ['too-long'];
|
|
2623
|
+
}
|
|
2624
|
+
else if (sources.some(source => !mockDashCase.test(source)) || new Set(sources).size !== sources.length || sources.includes(agent.id)) {
|
|
2625
|
+
errors['sources'] = ['invalid'];
|
|
2626
|
+
}
|
|
2627
|
+
if (description !== null && description.length > maxDescription$3) {
|
|
2628
|
+
errors['description'] = ['too-long'];
|
|
2629
|
+
}
|
|
2630
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > maxPerDay) {
|
|
2631
|
+
errors['maxPerSubjectPerDay'] = ['invalid'];
|
|
2632
|
+
}
|
|
2633
|
+
if (reason !== null && reason.length > mockMaxStateReason) {
|
|
2634
|
+
errors['reason'] = ['too-long'];
|
|
2635
|
+
}
|
|
2636
|
+
if (Object.keys(errors).length > 0) {
|
|
2637
|
+
return mockFailure('assistant-validation', errors);
|
|
2638
|
+
}
|
|
2639
|
+
// The same check as every load, refused here instead of left out of effect.
|
|
2640
|
+
const problem = this.problemOf(agent, subjectType, sources, limit);
|
|
2641
|
+
switch (problem) {
|
|
2642
|
+
case null:
|
|
2643
|
+
break;
|
|
2644
|
+
case 'outside-ceiling':
|
|
2645
|
+
return mockFailure('assistant-validation', ceiling.subjectTypes.some(item => item.subjectType === subjectType)
|
|
2646
|
+
? { sources: ['not-allowed'] }
|
|
2647
|
+
: { subjectType: ['not-allowed'] });
|
|
2648
|
+
case 'unknown-source':
|
|
2649
|
+
return mockFailure('assistant-validation', { sources: ['not-found'] });
|
|
2650
|
+
case 'not-worked-on':
|
|
2651
|
+
return mockFailure('assistant-validation', { subjectType: ['invalid'] });
|
|
2652
|
+
case 'limit-above-ceiling':
|
|
2653
|
+
return mockFailure('assistant-validation', { maxPerSubjectPerDay: ['not-allowed'] });
|
|
2654
|
+
default:
|
|
2655
|
+
return mockFailure('assistant-validation', { sources: ['invalid'] });
|
|
2656
|
+
}
|
|
2657
|
+
const key = `${agent.id}/${subjectType}`;
|
|
2658
|
+
const existing = this.stored.get(key);
|
|
2659
|
+
if ((existing?.version ?? null) !== (request?.expectedVersion ?? null)) {
|
|
2660
|
+
return mockFailure('assistant-version-conflict');
|
|
2661
|
+
}
|
|
2662
|
+
if (!existing && [...this.stored.values()].filter(item => item.agentId === agent.id).length >= maxHandOffs) {
|
|
2663
|
+
return mockFailure('assistant-configuration-limit');
|
|
2664
|
+
}
|
|
2665
|
+
const now = this.now();
|
|
2666
|
+
const saved = {
|
|
2667
|
+
agentId: agent.id,
|
|
2668
|
+
subjectType,
|
|
2669
|
+
version: (existing?.version ?? 0) + 1,
|
|
2670
|
+
sources,
|
|
2671
|
+
description,
|
|
2672
|
+
notifySource: request?.notifySource === true,
|
|
2673
|
+
maxPerSubjectPerDay: limit,
|
|
2674
|
+
toolId: `assistant.handoff.${agent.id}.${subjectType}`,
|
|
2675
|
+
changedAt: now,
|
|
2676
|
+
changedBy: 'current-user',
|
|
2677
|
+
reason,
|
|
2678
|
+
inEffect: true,
|
|
2679
|
+
problem: null,
|
|
2680
|
+
createdAt: existing?.createdAt ?? now,
|
|
2681
|
+
createdBy: existing?.createdBy ?? 'current-user'
|
|
2682
|
+
};
|
|
2683
|
+
this.stored.set(key, saved);
|
|
2684
|
+
const { agentId, ...body } = saved;
|
|
2685
|
+
return { status: 200, body };
|
|
2686
|
+
}
|
|
2687
|
+
remove(agent, subjectType, query) {
|
|
2688
|
+
if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
|
|
2689
|
+
return mockFailure('assistant-handoff-in-code');
|
|
2690
|
+
}
|
|
2691
|
+
const key = `${agent.id}/${subjectType}`;
|
|
2692
|
+
const existing = this.stored.get(key);
|
|
2693
|
+
if (!existing) {
|
|
2694
|
+
return mockFailure('assistant-handoff-not-found');
|
|
2695
|
+
}
|
|
2696
|
+
const expected = query.get('expectedVersion');
|
|
2697
|
+
if (expected !== null && Number(expected) !== existing.version) {
|
|
2698
|
+
return mockFailure('assistant-version-conflict');
|
|
2699
|
+
}
|
|
2700
|
+
this.stored.delete(key);
|
|
2701
|
+
return { status: 204 };
|
|
2702
|
+
}
|
|
2703
|
+
/**
|
|
2704
|
+
* The problem a hand-off would have, as the server checks it on save and on every load: a ceiling
|
|
2705
|
+
* that connects the agent about the type, a type it works on, sources with a run configuration
|
|
2706
|
+
* that the ceiling allows, and a limit within the ceiling's.
|
|
2707
|
+
*/
|
|
2708
|
+
problemOf(agent, subjectType, sources, limit) {
|
|
2709
|
+
const rules = this.rulesFor(agent, subjectType);
|
|
2710
|
+
if (!this.scenario.handOffsAsData) {
|
|
2711
|
+
return 'outside-ceiling';
|
|
2712
|
+
}
|
|
2713
|
+
if (!this.agents.isConfigured(agent)) {
|
|
2714
|
+
return 'unknown-agent';
|
|
2715
|
+
}
|
|
2716
|
+
if (this.codeHandOffsOf(agent.id).some(item => item.subjectType === subjectType)) {
|
|
2717
|
+
return 'conflicts-with-code';
|
|
2718
|
+
}
|
|
2719
|
+
if (!mockWorksOnCovers(agent, subjectType)) {
|
|
2720
|
+
return 'not-worked-on';
|
|
2721
|
+
}
|
|
2722
|
+
const known = sources.map(source => this.agents.find(source));
|
|
2723
|
+
if (known.some(source => !source || !this.agents.isConfigured(source))) {
|
|
2724
|
+
return 'unknown-source';
|
|
2725
|
+
}
|
|
2726
|
+
if (known.some(source => !rules.some(rule => this.allowsSource(rule, source)))) {
|
|
2727
|
+
return 'outside-ceiling';
|
|
2728
|
+
}
|
|
2729
|
+
return limit > this.maxPerDay() ? 'limit-above-ceiling' : null;
|
|
2730
|
+
}
|
|
2731
|
+
ceilingOf(agent) {
|
|
2732
|
+
const rules = (this.scenario.handOffsAsData?.rules ?? []).filter(rule => this.allowsTarget(rule, agent));
|
|
2733
|
+
if (rules.length === 0) {
|
|
2734
|
+
return null;
|
|
2735
|
+
}
|
|
2736
|
+
const configured = this.agents.all().filter(candidate => candidate.id !== agent.id && this.agents.isConfigured(candidate));
|
|
2737
|
+
const types = [...new Set(rules.map(rule => rule.subjectType))].sort();
|
|
2738
|
+
return {
|
|
2739
|
+
subjectTypes: types.map(subjectType => {
|
|
2740
|
+
const forType = rules.filter(rule => rule.subjectType === subjectType);
|
|
2741
|
+
return {
|
|
2742
|
+
subjectType,
|
|
2743
|
+
sources: configured.filter(candidate => forType.some(rule => this.allowsSource(rule, candidate))).map(candidate => candidate.id).sort(),
|
|
2744
|
+
sourcesCreatedInAdministration: forType.some(rule => rule.fromAgentsCreatedInAdministration === true)
|
|
2745
|
+
};
|
|
2746
|
+
}),
|
|
2747
|
+
maxPerSubjectPerDay: this.maxPerDay(),
|
|
2748
|
+
maxHandOffs,
|
|
2749
|
+
maxSources,
|
|
2750
|
+
maxDescriptionLength: maxDescription$3
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
rulesFor(agent, subjectType) {
|
|
2754
|
+
return (this.scenario.handOffsAsData?.rules ?? []).filter(rule => rule.subjectType === subjectType && this.allowsTarget(rule, agent));
|
|
2755
|
+
}
|
|
2756
|
+
allowsTarget(rule, agent) {
|
|
2757
|
+
return (rule.to ?? []).includes(agent.id) || (rule.toAgentsCreatedInAdministration === true && agent.source === 'admin');
|
|
2758
|
+
}
|
|
2759
|
+
allowsSource(rule, agent) {
|
|
2760
|
+
return (rule.from ?? []).includes(agent.id) || (rule.fromAgentsCreatedInAdministration === true && agent.source === 'admin');
|
|
2761
|
+
}
|
|
2762
|
+
maxPerDay() {
|
|
2763
|
+
return this.scenario.handOffsAsData?.maxPerSubjectPerDay ?? maxPerDay;
|
|
2764
|
+
}
|
|
2765
|
+
codeHandOffsOf(agentId) {
|
|
2766
|
+
const agent = (this.scenario.agents ?? []).find(item => item.id === agentId && item.source === 'code');
|
|
2767
|
+
return (agent?.codeHandOffs ?? []).map(item => ({
|
|
2768
|
+
subjectType: item.subjectType,
|
|
2769
|
+
sources: [...item.sources],
|
|
2770
|
+
description: item.description ?? null,
|
|
2771
|
+
notifySource: item.notifySource ?? false,
|
|
2772
|
+
maxPerSubjectPerDay: item.maxPerSubjectPerDay ?? defaultPerDay,
|
|
2773
|
+
toolId: `assistant.handoff.${agentId}.${item.subjectType}`
|
|
2774
|
+
}));
|
|
2775
|
+
}
|
|
2776
|
+
variantsOf(live) {
|
|
2777
|
+
const all = this.agents.all();
|
|
2778
|
+
const code = all.filter(agent => agent.source === 'code' && agent.shadowOf === live.id);
|
|
2779
|
+
const data = this.dataVariantsOf(live.id);
|
|
2780
|
+
const allowed = this.allowsVariantsOf(live.id, live) && this.agents.isConfigured(live) && isLive(live);
|
|
2781
|
+
return {
|
|
2782
|
+
agentId: live.id,
|
|
2783
|
+
allowsDataVariants: allowed,
|
|
2784
|
+
maxDataVariants: allowed ? this.maxVariants() : 0,
|
|
2785
|
+
variants: [
|
|
2786
|
+
...code.map(agent => ({
|
|
2787
|
+
agentId: agent.id, displayName: agent.displayName, source: 'code', hasRunSettings: true, inEffect: true, problem: null, version: agent.version
|
|
2788
|
+
})),
|
|
2789
|
+
...data.map(agent => {
|
|
2790
|
+
const hasRunSettings = this.agents.isConfigured(agent);
|
|
2791
|
+
return {
|
|
2792
|
+
agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
|
|
2793
|
+
};
|
|
2794
|
+
})
|
|
2795
|
+
]
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
dataVariantsOf(liveId) {
|
|
2799
|
+
return this.agents.all()
|
|
2800
|
+
.filter(agent => agent.source === 'admin' && agent.shadowOf === liveId)
|
|
2801
|
+
.sort((first, second) => first.id.localeCompare(second.id));
|
|
2802
|
+
}
|
|
2803
|
+
allowsVariantsOf(liveId, live) {
|
|
2804
|
+
const ceiling = this.scenario.shadowVariantsAsData;
|
|
2805
|
+
return ceiling !== undefined
|
|
2806
|
+
&& ((ceiling.of ?? []).includes(liveId) || (ceiling.ofAgentsCreatedInAdministration === true && (live?.source ?? 'admin') === 'admin'));
|
|
2807
|
+
}
|
|
2808
|
+
maxVariants() {
|
|
2809
|
+
return this.scenario.shadowVariantsAsData?.maxPerAgent ?? defaultVariants;
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2812
|
+
/** A live agent: no shadow variant and not in shadow by its code. */
|
|
2813
|
+
function isLive(agent) {
|
|
2814
|
+
return !agent.shadowOf && !(agent.source === 'code' && agent.shadow?.configured === true);
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2338
2817
|
/** The bounds of `AllowConsultation`, within which a ceiling may only lower the maxima. */
|
|
2339
2818
|
const bounds = {
|
|
2340
2819
|
toolCalls: { min: 0, max: 32, initial: 4 },
|
|
@@ -2349,14 +2828,19 @@ const roles = ['reader', 'participant', 'approver'];
|
|
|
2349
2828
|
const allRoutes = ['asker-decides', 'subject-agent-rights', 'subject-asker-rights'];
|
|
2350
2829
|
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2351
2830
|
const subjectTypePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2831
|
+
/** A tool id or a prefix ending in `.*`, as an agent's tool selectors are. */
|
|
2832
|
+
const selectorPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)*(?:\.\*)?$/;
|
|
2352
2833
|
/**
|
|
2353
2834
|
* Consultations of the mock API's agents: those in code from the scenario, read-only, and those
|
|
2354
2835
|
* defined as data, saved and removed as on the server within the ceilings in code
|
|
2355
2836
|
* (`consultationsAsData` for the application, `consultableAsData` for an agent in code). A save
|
|
2356
2837
|
* checks a consultation as the server does and refuses with the same codes and field errors; every
|
|
2357
2838
|
* read checks it again, so a consultation that no longer fits, for example after the run settings
|
|
2358
|
-
* changed whom the agent answers to
|
|
2359
|
-
*
|
|
2839
|
+
* changed whom the agent answers to or the agent's own tools no longer cover its tool selection,
|
|
2840
|
+
* stays with its problems. Data consultations in effect generate their consult and request tools in
|
|
2841
|
+
* the tool list and count for the agent's route switches. A new consultation starts off when its
|
|
2842
|
+
* request or the ceiling says so (`startsOff`); one that is off is checked as every read checks it but
|
|
2843
|
+
* offers nothing until `POST .../state` switches it on.
|
|
2360
2844
|
*/
|
|
2361
2845
|
class NhAssistantMockConsultations {
|
|
2362
2846
|
constructor(application, agents, rules, now) {
|
|
@@ -2365,6 +2849,7 @@ class NhAssistantMockConsultations {
|
|
|
2365
2849
|
this.stored = new Map();
|
|
2366
2850
|
this.codeAllowed = new Map();
|
|
2367
2851
|
this.codeDescriptions = new Map();
|
|
2852
|
+
this.codeTools = new Map();
|
|
2368
2853
|
this.agentCeilings = new Map();
|
|
2369
2854
|
this.applicationCeiling = application ? ceilingOf(application) : null;
|
|
2370
2855
|
for (const agent of agents) {
|
|
@@ -2374,6 +2859,7 @@ class NhAssistantMockConsultations {
|
|
|
2374
2859
|
if (agent.consultations !== undefined || agent.consultationDescriptions !== undefined) {
|
|
2375
2860
|
this.codeAllowed.set(agent.id, agent.consultations ?? {});
|
|
2376
2861
|
this.codeDescriptions.set(agent.id, agent.consultationDescriptions ?? {});
|
|
2862
|
+
this.codeTools.set(agent.id, agent.consultationTools ?? {});
|
|
2377
2863
|
}
|
|
2378
2864
|
if (agent.consultableAsData !== undefined) {
|
|
2379
2865
|
this.agentCeilings.set(agent.id, agent.consultableAsData);
|
|
@@ -2405,7 +2891,8 @@ class NhAssistantMockConsultations {
|
|
|
2405
2891
|
maxToolCalls: Math.min(application.maxToolCalls, narrowed.maxToolCalls),
|
|
2406
2892
|
maxTimeoutSeconds: Math.min(application.maxTimeoutSeconds, narrowed.maxTimeoutSeconds),
|
|
2407
2893
|
maxAnswerChars: Math.min(application.maxAnswerChars, narrowed.maxAnswerChars),
|
|
2408
|
-
maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages)
|
|
2894
|
+
maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages),
|
|
2895
|
+
...(application.startsOff === true || narrowed.startsOff === true ? { startsOff: true } : {})
|
|
2409
2896
|
};
|
|
2410
2897
|
}
|
|
2411
2898
|
/** The agent's consultations in code, by subject type. */
|
|
@@ -2415,6 +2902,7 @@ class NhAssistantMockConsultations {
|
|
|
2415
2902
|
return [];
|
|
2416
2903
|
}
|
|
2417
2904
|
const descriptions = this.codeDescriptions.get(agent.id) ?? {};
|
|
2905
|
+
const tools = this.codeTools.get(agent.id) ?? {};
|
|
2418
2906
|
const types = new Set([...Object.values(allowed).flat(), ...Object.keys(descriptions)]);
|
|
2419
2907
|
return [...types].sort().map(subjectType => {
|
|
2420
2908
|
const consultToolId = consultToolOf(agent.id, subjectType);
|
|
@@ -2426,7 +2914,8 @@ class NhAssistantMockConsultations {
|
|
|
2426
2914
|
exposure: ['local', 'agent'],
|
|
2427
2915
|
consultToolId,
|
|
2428
2916
|
requestToolId: null,
|
|
2429
|
-
askingAgents: this.askersOf(agent.id, consultToolId)
|
|
2917
|
+
askingAgents: this.askersOf(agent.id, consultToolId),
|
|
2918
|
+
toolSelectors: tools[subjectType] ? [...tools[subjectType]] : null
|
|
2430
2919
|
};
|
|
2431
2920
|
});
|
|
2432
2921
|
}
|
|
@@ -2474,8 +2963,18 @@ class NhAssistantMockConsultations {
|
|
|
2474
2963
|
}
|
|
2475
2964
|
return tools;
|
|
2476
2965
|
}
|
|
2477
|
-
/**
|
|
2478
|
-
|
|
2966
|
+
/**
|
|
2967
|
+
* A copy of the consultations defined as data; the returned function puts it back, for an import
|
|
2968
|
+
* of an agent package that fails as a whole and for its preview.
|
|
2969
|
+
*/
|
|
2970
|
+
snapshot() {
|
|
2971
|
+
return mockSnapshot(this.stored);
|
|
2972
|
+
}
|
|
2973
|
+
/** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`, and `POST .../state`. */
|
|
2974
|
+
handle(method, agent, subjectType, body, query, action) {
|
|
2975
|
+
if (action === 'state') {
|
|
2976
|
+
return method === 'POST' ? this.setState(agent, subjectType, body) : { status: 405 };
|
|
2977
|
+
}
|
|
2479
2978
|
if (method === 'DELETE') {
|
|
2480
2979
|
return this.remove(agent, subjectType, query);
|
|
2481
2980
|
}
|
|
@@ -2483,7 +2982,7 @@ class NhAssistantMockConsultations {
|
|
|
2483
2982
|
}
|
|
2484
2983
|
save(agent, subjectType, body) {
|
|
2485
2984
|
const request = (body ?? {});
|
|
2486
|
-
const reason = text$
|
|
2985
|
+
const reason = text$2(request.reason);
|
|
2487
2986
|
if (reason !== null && reason.length > mockMaxStateReason) {
|
|
2488
2987
|
return mockFailure('assistant-validation', { reason: ['too-long'] });
|
|
2489
2988
|
}
|
|
@@ -2498,12 +2997,16 @@ class NhAssistantMockConsultations {
|
|
|
2498
2997
|
return mockFailure('assistant-consultation-in-code');
|
|
2499
2998
|
}
|
|
2500
2999
|
// A new consultation starts with the defaults of code, capped by the ceiling.
|
|
2501
|
-
const description = text$
|
|
2502
|
-
const instructions = text$
|
|
2503
|
-
const minimumRole = (text$
|
|
2504
|
-
const proposalRole = (text$
|
|
3000
|
+
const description = text$2(request.description);
|
|
3001
|
+
const instructions = text$2(request.instructions);
|
|
3002
|
+
const minimumRole = (text$2(request.minimumRole) ?? 'reader');
|
|
3003
|
+
const proposalRole = (text$2(request.proposalRole) ?? 'participant');
|
|
2505
3004
|
const routes = (request.routes ?? []).map(route => (typeof route === 'string' ? route.trim() : ''));
|
|
2506
|
-
|
|
3005
|
+
// No selection, or an empty one, offers every tool of the agent.
|
|
3006
|
+
const toolSelectors = Array.isArray(request.toolSelectors) && request.toolSelectors.length > 0
|
|
3007
|
+
? request.toolSelectors.map(selector => (typeof selector === 'string' ? selector.trim() : ''))
|
|
3008
|
+
: null;
|
|
3009
|
+
const classification = text$2(request.dataClassification);
|
|
2507
3010
|
const limits = {
|
|
2508
3011
|
maxToolCalls: request.maxToolCalls ?? Math.min(bounds.toolCalls.initial, ceiling.maxToolCalls),
|
|
2509
3012
|
timeoutSeconds: request.timeoutSeconds ?? Math.min(bounds.timeoutSeconds.initial, ceiling.maxTimeoutSeconds),
|
|
@@ -2535,6 +3038,9 @@ class NhAssistantMockConsultations {
|
|
|
2535
3038
|
if (!validRoutes(routes)) {
|
|
2536
3039
|
add('routes', 'invalid');
|
|
2537
3040
|
}
|
|
3041
|
+
if (toolSelectors !== null && !validSelectors(toolSelectors)) {
|
|
3042
|
+
add('toolSelectors', 'invalid');
|
|
3043
|
+
}
|
|
2538
3044
|
if (!within(limits.maxToolCalls, bounds.toolCalls)
|
|
2539
3045
|
|| !within(limits.timeoutSeconds, bounds.timeoutSeconds)
|
|
2540
3046
|
|| !within(limits.maxAnswerChars, bounds.answerChars)
|
|
@@ -2544,6 +3050,10 @@ class NhAssistantMockConsultations {
|
|
|
2544
3050
|
if (classification !== null && !classifications.includes(classification)) {
|
|
2545
3051
|
add('dataClassification', 'invalid');
|
|
2546
3052
|
}
|
|
3053
|
+
// Only a new consultation starts off; one that exists keeps whether it is on.
|
|
3054
|
+
if (request.startsOff === true && request.expectedVersion != null) {
|
|
3055
|
+
add('startsOff', 'invalid');
|
|
3056
|
+
}
|
|
2547
3057
|
if (Object.keys(errors).length > 0) {
|
|
2548
3058
|
return mockFailure('assistant-validation', errors);
|
|
2549
3059
|
}
|
|
@@ -2556,14 +3066,16 @@ class NhAssistantMockConsultations {
|
|
|
2556
3066
|
description: description,
|
|
2557
3067
|
instructions,
|
|
2558
3068
|
minimumRole,
|
|
2559
|
-
askerPolicy: text$
|
|
3069
|
+
askerPolicy: text$2(request.askerPolicy),
|
|
2560
3070
|
routes,
|
|
2561
3071
|
proposalRole,
|
|
2562
|
-
proposalPolicy: text$
|
|
3072
|
+
proposalPolicy: text$2(request.proposalPolicy),
|
|
3073
|
+
toolSelectors,
|
|
2563
3074
|
...limits,
|
|
2564
3075
|
allowRequests: request.allowRequests === true,
|
|
2565
3076
|
exposeToMcp: request.exposeToMcp === true,
|
|
2566
3077
|
dataClassification: classification,
|
|
3078
|
+
off: existing ? existing.off : request.startsOff === true || ceiling.startsOff === true,
|
|
2567
3079
|
createdAt: existing?.createdAt ?? now,
|
|
2568
3080
|
createdBy: existing?.createdBy ?? 'current-user',
|
|
2569
3081
|
changedAt: now,
|
|
@@ -2584,6 +3096,31 @@ class NhAssistantMockConsultations {
|
|
|
2584
3096
|
this.stored.set(key$2(agent.id, subjectType), row);
|
|
2585
3097
|
return { status: 200, body: this.toConsultation(agent, row) };
|
|
2586
3098
|
}
|
|
3099
|
+
/** `POST admin/agents/{id}/consultations/{subjectType}/state`: on or off, without a new version. */
|
|
3100
|
+
setState(agent, subjectType, body) {
|
|
3101
|
+
const request = body;
|
|
3102
|
+
if (request?.state !== 'on' && request?.state !== 'off') {
|
|
3103
|
+
return mockFailure('assistant-validation', { state: [request?.state === undefined ? 'required' : 'invalid'] });
|
|
3104
|
+
}
|
|
3105
|
+
if (typeof request.reason === 'string' && request.reason.trim().length > mockMaxStateReason) {
|
|
3106
|
+
return mockFailure('assistant-validation', { reason: ['too-long'] });
|
|
3107
|
+
}
|
|
3108
|
+
if (this.inCode(agent.id, subjectType)) {
|
|
3109
|
+
return mockFailure('assistant-consultation-in-code');
|
|
3110
|
+
}
|
|
3111
|
+
const existing = this.stored.get(key$2(agent.id, subjectType));
|
|
3112
|
+
if (!existing) {
|
|
3113
|
+
return mockFailure('assistant-consultation-not-found');
|
|
3114
|
+
}
|
|
3115
|
+
const off = request.state === 'off';
|
|
3116
|
+
if (existing.off !== off) {
|
|
3117
|
+
existing.off = off;
|
|
3118
|
+
existing.changedAt = this.now();
|
|
3119
|
+
existing.changedBy = 'current-user';
|
|
3120
|
+
existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
3121
|
+
}
|
|
3122
|
+
return { status: 200, body: this.toConsultation(agent, existing) };
|
|
3123
|
+
}
|
|
2587
3124
|
remove(agent, subjectType, query) {
|
|
2588
3125
|
const reason = query.get('reason');
|
|
2589
3126
|
if (reason !== null && reason.trim().length > mockMaxStateReason) {
|
|
@@ -2610,7 +3147,7 @@ class NhAssistantMockConsultations {
|
|
|
2610
3147
|
* no longer fit and with limits capped at the ceiling, each with a problem.
|
|
2611
3148
|
*/
|
|
2612
3149
|
check(agent, row) {
|
|
2613
|
-
const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false });
|
|
3150
|
+
const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false, toolSelectors: [] });
|
|
2614
3151
|
const ceiling = this.ceilingOf(agent);
|
|
2615
3152
|
if (!ceiling) {
|
|
2616
3153
|
return out('not-consultable');
|
|
@@ -2676,7 +3213,19 @@ class NhAssistantMockConsultations {
|
|
|
2676
3213
|
|| row.maxHistoryMessages > ceiling.maxHistoryMessages) {
|
|
2677
3214
|
problems.push('limit-above-ceiling');
|
|
2678
3215
|
}
|
|
2679
|
-
|
|
3216
|
+
// The selection only narrows the agent's own tools; one they no longer cover drops, and a
|
|
3217
|
+
// selection with none left offers no tools, never every tool of the agent.
|
|
3218
|
+
let toolSelectors = null;
|
|
3219
|
+
if (row.toolSelectors !== null) {
|
|
3220
|
+
toolSelectors = row.toolSelectors.filter(selector => agent.toolSelectors.some(own => selectorCovers(own, selector)));
|
|
3221
|
+
if (toolSelectors.length < row.toolSelectors.length) {
|
|
3222
|
+
problems.push('tools-not-allowed');
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
// Off, it is checked as every read checks it, and offers nothing until it is switched on.
|
|
3226
|
+
return row.off
|
|
3227
|
+
? { inEffect: false, problems, routes: [], allowRequests: false, toolSelectors: [] }
|
|
3228
|
+
: { inEffect: true, problems, routes: kept, allowRequests, toolSelectors };
|
|
2680
3229
|
}
|
|
2681
3230
|
namesOnlyTheInitiator(policy) {
|
|
2682
3231
|
const summary = this.rules.approvalPolicySummaries().find(item => item.name === policy);
|
|
@@ -2689,6 +3238,8 @@ class NhAssistantMockConsultations {
|
|
|
2689
3238
|
return {
|
|
2690
3239
|
...stored,
|
|
2691
3240
|
routes: [...row.routes],
|
|
3241
|
+
toolSelectors: row.toolSelectors ? [...row.toolSelectors] : null,
|
|
3242
|
+
toolSelectorsInEffect: check.toolSelectors ? [...check.toolSelectors] : null,
|
|
2692
3243
|
instructionsHash: row.instructions === null ? null : mockHashText(row.instructions),
|
|
2693
3244
|
inEffect: check.inEffect,
|
|
2694
3245
|
problem: check.problems[0] ?? null,
|
|
@@ -2728,7 +3279,9 @@ function ceilingOf(ceiling) {
|
|
|
2728
3279
|
maxAnswerChars: ceiling.maxAnswerChars ?? bounds.answerChars.max,
|
|
2729
3280
|
maxHistoryMessages: ceiling.maxHistoryMessages ?? bounds.history.max,
|
|
2730
3281
|
maxConsultations,
|
|
2731
|
-
maxInstructionsLength: maxInstructions$1
|
|
3282
|
+
maxInstructionsLength: maxInstructions$1,
|
|
3283
|
+
// Reported only when the application lets new consultations start off, as older servers omit it.
|
|
3284
|
+
...(ceiling.startsOff === true ? { startsOff: true } : {})
|
|
2732
3285
|
};
|
|
2733
3286
|
}
|
|
2734
3287
|
/** The field error of each problem a consultation would have, as the server maps them on save. */
|
|
@@ -2750,7 +3303,8 @@ function fieldFailure(problems) {
|
|
|
2750
3303
|
'no-approver': ['proposalPolicy', 'invalid'],
|
|
2751
3304
|
'limit-above-ceiling': ['limits', 'not-allowed'],
|
|
2752
3305
|
'requests-not-allowed': ['allowRequests', 'not-allowed'],
|
|
2753
|
-
'mcp-not-allowed': ['exposeToMcp', 'not-allowed']
|
|
3306
|
+
'mcp-not-allowed': ['exposeToMcp', 'not-allowed'],
|
|
3307
|
+
'tools-not-allowed': ['toolSelectors', 'not-allowed']
|
|
2754
3308
|
};
|
|
2755
3309
|
const errors = {};
|
|
2756
3310
|
for (const problem of problems) {
|
|
@@ -2765,6 +3319,15 @@ function validRoutes(routes) {
|
|
|
2765
3319
|
&& new Set(routes).size === routes.length
|
|
2766
3320
|
&& !(routes.includes('subject-agent-rights') && routes.includes('subject-asker-rights'));
|
|
2767
3321
|
}
|
|
3322
|
+
/** Valid selectors, each once. */
|
|
3323
|
+
function validSelectors(selectors) {
|
|
3324
|
+
return selectors.every(selector => selector.length <= 128 && selectorPattern.test(selector))
|
|
3325
|
+
&& new Set(selectors).size === selectors.length;
|
|
3326
|
+
}
|
|
3327
|
+
/** The agent's selector covers a consultation's selector: the same tool, or a prefix that includes it. */
|
|
3328
|
+
function selectorCovers(agentSelector, selector) {
|
|
3329
|
+
return agentSelector === selector || (agentSelector.endsWith('.*') && selector.startsWith(agentSelector.slice(0, -1)));
|
|
3330
|
+
}
|
|
2768
3331
|
function within(value, range) {
|
|
2769
3332
|
return Number.isInteger(value) && value >= range.min && value <= range.max;
|
|
2770
3333
|
}
|
|
@@ -2782,11 +3345,116 @@ function covers$1(selector, toolId) {
|
|
|
2782
3345
|
function key$2(agentId, subjectType) {
|
|
2783
3346
|
return `${agentId}/${subjectType}`;
|
|
2784
3347
|
}
|
|
2785
|
-
function text$
|
|
3348
|
+
function text$2(value) {
|
|
2786
3349
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
2787
3350
|
}
|
|
2788
3351
|
|
|
2789
|
-
|
|
3352
|
+
/**
|
|
3353
|
+
* The application's text assets of the mock API, as its `INhAiTextAssetSource` would supply them:
|
|
3354
|
+
* `GET admin/instruction-assets` lists them without content and `GET admin/instruction-assets/{id}/versions/{version}`
|
|
3355
|
+
* reads one. A skill version or an agent created in the administration that names one is checked as
|
|
3356
|
+
* the server checks it: the asset must exist with the hash the request names. `setAsset` and
|
|
3357
|
+
* `removeAsset` change the assets afterwards, so a reference that no longer resolves shows its problem
|
|
3358
|
+
* (`asset-changed`, `asset-missing`), as after the server's next load. Without `instructionAssets` in
|
|
3359
|
+
* the scenario the application offers none and the run catalog says so.
|
|
3360
|
+
*/
|
|
3361
|
+
class NhAssistantMockInstructionAssets {
|
|
3362
|
+
constructor(assets) {
|
|
3363
|
+
this.assets = new Map();
|
|
3364
|
+
this.supported = assets !== undefined;
|
|
3365
|
+
for (const asset of assets ?? []) {
|
|
3366
|
+
this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
|
|
3367
|
+
}
|
|
3368
|
+
}
|
|
3369
|
+
/** Publishes or changes one version of an asset. */
|
|
3370
|
+
setAsset(asset) {
|
|
3371
|
+
this.assets.set(keyOf$1(asset.assetId, asset.version), { ...asset });
|
|
3372
|
+
}
|
|
3373
|
+
/** Removes one version of an asset. */
|
|
3374
|
+
removeAsset(assetId, version) {
|
|
3375
|
+
this.assets.delete(keyOf$1(assetId, version));
|
|
3376
|
+
}
|
|
3377
|
+
/** `admin/instruction-assets[/{id}/versions/{version}]`; `segments` start after `instruction-assets`. */
|
|
3378
|
+
handle(method, segments) {
|
|
3379
|
+
if (method !== 'GET') {
|
|
3380
|
+
return { status: 405 };
|
|
3381
|
+
}
|
|
3382
|
+
if (segments.length === 0) {
|
|
3383
|
+
const list = {
|
|
3384
|
+
supported: this.supported,
|
|
3385
|
+
assets: [...this.assets.values()].map(asset => ({
|
|
3386
|
+
assetId: asset.assetId,
|
|
3387
|
+
version: asset.version,
|
|
3388
|
+
hash: mockHashText(asset.content),
|
|
3389
|
+
replacedByAssetId: asset.replacedByAssetId ?? null,
|
|
3390
|
+
deprecatedAt: asset.deprecatedAt ?? null
|
|
3391
|
+
}))
|
|
3392
|
+
};
|
|
3393
|
+
return { status: 200, body: list };
|
|
3394
|
+
}
|
|
3395
|
+
const [assetId, versions, version] = segments;
|
|
3396
|
+
const asset = segments.length === 3 && versions === 'versions' ? this.assets.get(keyOf$1(assetId, Number(version))) : undefined;
|
|
3397
|
+
if (!this.supported || !asset) {
|
|
3398
|
+
return mockFailure('assistant-instructions-asset-not-found');
|
|
3399
|
+
}
|
|
3400
|
+
const text = {
|
|
3401
|
+
assetId: asset.assetId,
|
|
3402
|
+
version: asset.version,
|
|
3403
|
+
hash: mockHashText(asset.content),
|
|
3404
|
+
content: asset.content,
|
|
3405
|
+
problem: null
|
|
3406
|
+
};
|
|
3407
|
+
return { status: 200, body: text };
|
|
3408
|
+
}
|
|
3409
|
+
/**
|
|
3410
|
+
* Adds the field errors of a reference a save names, as the server does: `instructionsAsset`
|
|
3411
|
+
* `not-allowed` without a source, `invalid` for a malformed reference, `not-found` for a missing
|
|
3412
|
+
* asset, and `instructionsAsset.hash` `invalid` for another hash.
|
|
3413
|
+
*/
|
|
3414
|
+
check(reference, errors) {
|
|
3415
|
+
if (!this.supported) {
|
|
3416
|
+
errors['instructionsAsset'] = ['not-allowed'];
|
|
3417
|
+
return;
|
|
3418
|
+
}
|
|
3419
|
+
if (typeof reference.assetId !== 'string' || !mockDashCase.test(reference.assetId) || !Number.isInteger(reference.version) || reference.version < 1) {
|
|
3420
|
+
errors['instructionsAsset'] = ['invalid'];
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
const problem = this.problemOf(reference);
|
|
3424
|
+
if (problem === 'asset-missing') {
|
|
3425
|
+
errors['instructionsAsset'] = ['not-found'];
|
|
3426
|
+
}
|
|
3427
|
+
else if (problem === 'asset-changed') {
|
|
3428
|
+
errors['instructionsAsset.hash'] = ['invalid'];
|
|
3429
|
+
}
|
|
3430
|
+
else if (problem !== null) {
|
|
3431
|
+
errors['instructionsAsset'] = ['invalid'];
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
/** Why the reference does not resolve to its hash now, or `null` when it does. */
|
|
3435
|
+
problemOf(reference) {
|
|
3436
|
+
if (!this.supported) {
|
|
3437
|
+
return 'no-asset-source';
|
|
3438
|
+
}
|
|
3439
|
+
const asset = this.assets.get(keyOf$1(reference.assetId, reference.version));
|
|
3440
|
+
if (!asset) {
|
|
3441
|
+
return 'asset-missing';
|
|
3442
|
+
}
|
|
3443
|
+
if (asset.content.trim().length === 0 || asset.content.length > mockMaxInstructions) {
|
|
3444
|
+
return 'asset-invalid';
|
|
3445
|
+
}
|
|
3446
|
+
return mockHashText(asset.content) === reference.hash ? null : 'asset-changed';
|
|
3447
|
+
}
|
|
3448
|
+
/** The text the reference resolves to, or `null` when it does not resolve. */
|
|
3449
|
+
textOf(reference) {
|
|
3450
|
+
return this.problemOf(reference) === null ? this.assets.get(keyOf$1(reference.assetId, reference.version)).content : null;
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
function keyOf$1(assetId, version) {
|
|
3454
|
+
return `${assetId}@${version}`;
|
|
3455
|
+
}
|
|
3456
|
+
|
|
3457
|
+
const dashCase$5 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2790
3458
|
const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2791
3459
|
const accountabilities = ['agent', 'subject-responsible', 'service-account'];
|
|
2792
3460
|
const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
|
|
@@ -2811,17 +3479,22 @@ const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'max
|
|
|
2811
3479
|
* checks the names against the scenario's run catalog as the server checks them against the code;
|
|
2812
3480
|
* new skills and triggers start in shadow. A schedule without a subject set runs for the whole
|
|
2813
3481
|
* 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`.
|
|
2815
|
-
* a
|
|
3482
|
+
* agent works on stays, out of effect, with the problem `not-worked-on`. Triggers have append-only
|
|
3483
|
+
* versions as skills do: a change applies at once or, with `apply` `shadow`, becomes the candidate next
|
|
3484
|
+
* to the live version, which the administrator promotes or discards. The mock runs no agents, so a
|
|
3485
|
+
* comparison counts nothing.
|
|
2816
3486
|
*/
|
|
2817
3487
|
class NhAssistantMockRunConfiguration {
|
|
2818
|
-
constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario)) {
|
|
3488
|
+
constructor(scenario, agents, now, accounts = new NhAssistantMockAccounts(scenario), instructionAssets = new NhAssistantMockInstructionAssets(scenario.instructionAssets)) {
|
|
2819
3489
|
this.agents = agents;
|
|
2820
3490
|
this.now = now;
|
|
2821
3491
|
this.accounts = accounts;
|
|
3492
|
+
this.instructionAssets = instructionAssets;
|
|
2822
3493
|
this.settings = new Map();
|
|
2823
3494
|
this.skills = new Map();
|
|
2824
3495
|
this.triggers = new Map();
|
|
3496
|
+
/** Every version of every data trigger by trigger id, live and candidate included, oldest first. */
|
|
3497
|
+
this.triggerVersions = new Map();
|
|
2825
3498
|
const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
|
|
2826
3499
|
this.consultations = new NhAssistantMockConsultations(scenario.consultationsAsData, scenario.agents ?? [], {
|
|
2827
3500
|
find: agentId => this.agents.find(agentId),
|
|
@@ -2854,7 +3527,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2854
3527
|
divisions: scenario.runCatalog?.divisions ?? false,
|
|
2855
3528
|
// The platform's default file limits, unless the scenario's application chose its own.
|
|
2856
3529
|
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
|
|
2857
|
-
consultationCeiling: this.consultations.applicationCeiling
|
|
3530
|
+
consultationCeiling: this.consultations.applicationCeiling,
|
|
3531
|
+
instructionAssets: instructionAssets.supported
|
|
2858
3532
|
};
|
|
2859
3533
|
this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
|
|
2860
3534
|
...trigger,
|
|
@@ -2878,6 +3552,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
2878
3552
|
get profileNames() {
|
|
2879
3553
|
return [...this.catalog.profiles];
|
|
2880
3554
|
}
|
|
3555
|
+
/**
|
|
3556
|
+
* A copy of the run settings, skills, triggers and consultations; the returned function puts it
|
|
3557
|
+
* back, for an import of an agent package that fails as a whole and for its preview.
|
|
3558
|
+
*/
|
|
3559
|
+
snapshot() {
|
|
3560
|
+
const restoreOwn = mockSnapshot(this.settings, this.skills, this.triggers, this.triggerVersions);
|
|
3561
|
+
const restoreConsultations = this.consultations.snapshot();
|
|
3562
|
+
return () => {
|
|
3563
|
+
restoreOwn();
|
|
3564
|
+
restoreConsultations();
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
2881
3567
|
/**
|
|
2882
3568
|
* The agent's consultations defined as data in effect with their routes in effect, which count for
|
|
2883
3569
|
* its route switches.
|
|
@@ -2895,8 +3581,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2895
3581
|
}
|
|
2896
3582
|
/**
|
|
2897
3583
|
* `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]`,
|
|
2898
|
-
* `triggers/{triggerId}[/shadow]` and `consultations/{subjectType}`;
|
|
2899
|
-
* agent id.
|
|
3584
|
+
* `triggers/{triggerId}[/shadow|/versions|/candidate/...]` and `consultations/{subjectType}[/state]`;
|
|
3585
|
+
* `segments` start after the agent id.
|
|
2900
3586
|
*/
|
|
2901
3587
|
handle(method, agentId, segments, body, query) {
|
|
2902
3588
|
const agent = this.agents.find(agentId);
|
|
@@ -2919,14 +3605,22 @@ class NhAssistantMockRunConfiguration {
|
|
|
2919
3605
|
}
|
|
2920
3606
|
return segments.length === 2 ? this.skill(method, agent, id, body, query) : null;
|
|
2921
3607
|
}
|
|
3608
|
+
if (area === 'triggers' && id !== undefined && (segments.length === 3 && action === 'versions' || segments.length === 4 && action === 'candidate')) {
|
|
3609
|
+
if (!agent) {
|
|
3610
|
+
return mockFailure('assistant-agent-not-found');
|
|
3611
|
+
}
|
|
3612
|
+
return action === 'versions'
|
|
3613
|
+
? method === 'GET' ? { status: 200, body: this.triggerHistory(agent.id, id) } : { status: 405 }
|
|
3614
|
+
: this.triggerCandidate(method, agent, id, child, body);
|
|
3615
|
+
}
|
|
2922
3616
|
if (area === 'triggers' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'shadow'))) {
|
|
2923
3617
|
if (!agent) {
|
|
2924
3618
|
return mockFailure('assistant-agent-not-found');
|
|
2925
3619
|
}
|
|
2926
3620
|
return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
|
|
2927
3621
|
}
|
|
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');
|
|
3622
|
+
if (area === 'consultations' && id !== undefined && (segments.length === 2 || (segments.length === 3 && action === 'state'))) {
|
|
3623
|
+
return agent ? this.consultations.handle(method, agent, id, body, query, action) : mockFailure('assistant-agent-not-found');
|
|
2930
3624
|
}
|
|
2931
3625
|
return null;
|
|
2932
3626
|
}
|
|
@@ -2937,7 +3631,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2937
3631
|
.filter(trigger => this.ownerOf(trigger) === agent.id)
|
|
2938
3632
|
.map(trigger => {
|
|
2939
3633
|
const problem = this.triggerProblem(agent, trigger);
|
|
2940
|
-
|
|
3634
|
+
const live = problem ? { ...withoutOwner(trigger), inEffect: false, problem } : withoutOwner(trigger);
|
|
3635
|
+
const candidate = this.candidateOf(trigger.triggerId);
|
|
3636
|
+
return { ...live, candidate: candidate ? this.candidateDto(agent, candidate) : null };
|
|
2941
3637
|
});
|
|
2942
3638
|
return {
|
|
2943
3639
|
agentId: agent.id,
|
|
@@ -2949,9 +3645,16 @@ class NhAssistantMockRunConfiguration {
|
|
|
2949
3645
|
codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
|
|
2950
3646
|
dataTriggers,
|
|
2951
3647
|
problems: [
|
|
3648
|
+
...dataSkills
|
|
3649
|
+
.flatMap(skill => [{ kind: 'skill', skill }, ...(skill.candidate ? [{ kind: 'candidate', skill: skill.candidate }] : [])])
|
|
3650
|
+
.filter(entry => entry.skill.instructionsAsset && entry.skill.problem !== null)
|
|
3651
|
+
.map(entry => ({ agentId: agent.id, kind: entry.kind, id: entry.skill.skillId, code: entry.skill.problem })),
|
|
2952
3652
|
...dataTriggers
|
|
2953
3653
|
.filter(trigger => trigger.problem !== null)
|
|
2954
3654
|
.map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
|
|
3655
|
+
...dataTriggers
|
|
3656
|
+
.filter(trigger => trigger.candidate && trigger.candidate.problem !== null)
|
|
3657
|
+
.map(trigger => ({ agentId: agent.id, kind: 'trigger-candidate', id: trigger.triggerId, code: trigger.candidate.problem })),
|
|
2955
3658
|
...this.consultations.problems(agent)
|
|
2956
3659
|
],
|
|
2957
3660
|
source: agent.source,
|
|
@@ -3008,7 +3711,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
3008
3711
|
}
|
|
3009
3712
|
if (this.liveSkills(agent.id).length > 0
|
|
3010
3713
|
|| [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)
|
|
3011
|
-
|| this.consultations.has(agent.id)
|
|
3714
|
+
|| this.consultations.has(agent.id)
|
|
3715
|
+
|| this.agents.acceptsDataHandOffs?.(agent.id)) {
|
|
3012
3716
|
return mockFailure('assistant-run-settings-in-use');
|
|
3013
3717
|
}
|
|
3014
3718
|
this.settings.delete(agent.id);
|
|
@@ -3018,7 +3722,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3018
3722
|
return { status: 405 };
|
|
3019
3723
|
}
|
|
3020
3724
|
const request = (body ?? {});
|
|
3021
|
-
const serviceAccount = text(request.serviceAccountActorId);
|
|
3725
|
+
const serviceAccount = text$1(request.serviceAccountActorId);
|
|
3022
3726
|
const errors = {};
|
|
3023
3727
|
if (!accountabilities.includes(request.accountability)) {
|
|
3024
3728
|
errors['accountability'] = [request.accountability ? 'invalid' : 'required'];
|
|
@@ -3061,18 +3765,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
3061
3765
|
delegateFromInitiator: request.delegateFromInitiator ?? false,
|
|
3062
3766
|
allowGatewayReads: request.allowGatewayReads ?? false,
|
|
3063
3767
|
elevatedReads: request.elevatedReads ?? 'restricted',
|
|
3064
|
-
approvalPolicy: text(request.approvalPolicy),
|
|
3768
|
+
approvalPolicy: text$1(request.approvalPolicy),
|
|
3065
3769
|
retryCount: request.retryCount ?? 3,
|
|
3066
3770
|
dailyToolCalls: request.dailyToolCalls ?? null,
|
|
3067
3771
|
dailyTokens: request.dailyTokens ?? null,
|
|
3068
3772
|
maxConcurrentRuns: request.maxConcurrentRuns ?? 4,
|
|
3069
3773
|
maxRunsPerSubjectPerDay: request.maxRunsPerSubjectPerDay ?? 10,
|
|
3070
3774
|
maxTokensPerRun: request.maxTokensPerRun ?? null,
|
|
3071
|
-
triageProfile: text(request.triageProfile),
|
|
3072
|
-
ruleHandler: text(request.ruleHandler),
|
|
3775
|
+
triageProfile: text$1(request.triageProfile),
|
|
3776
|
+
ruleHandler: text$1(request.ruleHandler),
|
|
3073
3777
|
changedAt: this.now(),
|
|
3074
3778
|
changedBy: 'current-user',
|
|
3075
|
-
reason: text(request.reason),
|
|
3779
|
+
reason: text$1(request.reason),
|
|
3076
3780
|
inEffect: true,
|
|
3077
3781
|
problem: null,
|
|
3078
3782
|
acceptsFiles: request.acceptsFiles ?? false,
|
|
@@ -3110,9 +3814,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
3110
3814
|
return { status: 405 };
|
|
3111
3815
|
}
|
|
3112
3816
|
const request = (body ?? {});
|
|
3113
|
-
const description = text(request.description);
|
|
3817
|
+
const description = text$1(request.description);
|
|
3114
3818
|
const errors = {};
|
|
3115
|
-
if (!dashCase$
|
|
3819
|
+
if (!dashCase$5.test(skillId)) {
|
|
3116
3820
|
errors['skillId'] = ['invalid'];
|
|
3117
3821
|
}
|
|
3118
3822
|
if (description === null) {
|
|
@@ -3124,6 +3828,14 @@ class NhAssistantMockRunConfiguration {
|
|
|
3124
3828
|
if ((request.instructions ?? '').length > maxInstructions) {
|
|
3125
3829
|
errors['instructions'] = ['too-long'];
|
|
3126
3830
|
}
|
|
3831
|
+
// A version takes its instructions from an asset of the application or has text of its own.
|
|
3832
|
+
const instructionsAsset = request.instructionsAsset ?? null;
|
|
3833
|
+
if (instructionsAsset !== null) {
|
|
3834
|
+
if (text$1(request.instructions) !== null) {
|
|
3835
|
+
errors['instructions'] = ['not-allowed'];
|
|
3836
|
+
}
|
|
3837
|
+
this.instructionAssets.check(instructionsAsset, errors);
|
|
3838
|
+
}
|
|
3127
3839
|
if ((request.tools ?? []).some(tool => !agent.toolSelectors.some(selector => covers(selector, tool)))) {
|
|
3128
3840
|
errors['tools'] = ['invalid'];
|
|
3129
3841
|
}
|
|
@@ -3140,7 +3852,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3140
3852
|
if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
|
|
3141
3853
|
errors['startsOff'] = ['invalid'];
|
|
3142
3854
|
}
|
|
3143
|
-
const classification = text(request.dataClassification);
|
|
3855
|
+
const classification = text$1(request.dataClassification);
|
|
3144
3856
|
if (classification !== null && !dataClassifications.includes(classification)) {
|
|
3145
3857
|
errors['dataClassification'] = ['invalid'];
|
|
3146
3858
|
}
|
|
@@ -3163,27 +3875,30 @@ class NhAssistantMockRunConfiguration {
|
|
|
3163
3875
|
version.status = 'superseded';
|
|
3164
3876
|
}
|
|
3165
3877
|
}
|
|
3166
|
-
const instructions = text(request.instructions);
|
|
3878
|
+
const instructions = instructionsAsset === null ? text$1(request.instructions) : null;
|
|
3167
3879
|
const saved = {
|
|
3168
3880
|
skillId,
|
|
3169
3881
|
version: versions.reduce((latest, version) => Math.max(latest, version.version), 0) + 1,
|
|
3170
3882
|
status: candidate ? 'candidate' : 'live',
|
|
3171
3883
|
description: description,
|
|
3172
3884
|
instructions,
|
|
3173
|
-
instructionsHash: instructions === null ? null : hashText(instructions),
|
|
3885
|
+
instructionsHash: instructionsAsset?.hash ?? (instructions === null ? null : hashText(instructions)),
|
|
3886
|
+
instructionsAsset: instructionsAsset === null
|
|
3887
|
+
? null
|
|
3888
|
+
: { assetId: instructionsAsset.assetId, version: instructionsAsset.version, hash: instructionsAsset.hash },
|
|
3174
3889
|
tools: [...new Set(request.tools ?? [])],
|
|
3175
|
-
profile: text(request.profile),
|
|
3890
|
+
profile: text$1(request.profile),
|
|
3176
3891
|
dataClassification: classification,
|
|
3177
3892
|
acceptsFiles: request.acceptsFiles ?? false,
|
|
3178
3893
|
...storedFileLimits(request),
|
|
3179
|
-
ruleHandler: text(request.ruleHandler),
|
|
3894
|
+
ruleHandler: text$1(request.ruleHandler),
|
|
3180
3895
|
maxTokensPerRun: request.maxTokensPerRun ?? null,
|
|
3181
3896
|
dailyToolCalls: request.dailyToolCalls ?? null,
|
|
3182
3897
|
dailyTokens: request.dailyTokens ?? null,
|
|
3183
|
-
approvalPolicy: text(request.approvalPolicy),
|
|
3898
|
+
approvalPolicy: text$1(request.approvalPolicy),
|
|
3184
3899
|
changedAt: this.now(),
|
|
3185
3900
|
changedBy: 'current-user',
|
|
3186
|
-
reason: text(request.reason),
|
|
3901
|
+
reason: text$1(request.reason),
|
|
3187
3902
|
inEffect: true,
|
|
3188
3903
|
problem: null
|
|
3189
3904
|
};
|
|
@@ -3248,7 +3963,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
3248
3963
|
instructionsHash: version.instructionsHash,
|
|
3249
3964
|
createdAt: version.changedAt,
|
|
3250
3965
|
createdBy: version.changedBy,
|
|
3251
|
-
reason: version.reason
|
|
3966
|
+
reason: version.reason,
|
|
3967
|
+
instructionsAsset: version.instructionsAsset ?? null
|
|
3252
3968
|
}));
|
|
3253
3969
|
}
|
|
3254
3970
|
trigger(method, agent, triggerId, body, query) {
|
|
@@ -3269,6 +3985,10 @@ class NhAssistantMockRunConfiguration {
|
|
|
3269
3985
|
if (!versionMatches(query, existing.version)) {
|
|
3270
3986
|
return mockFailure('assistant-version-conflict');
|
|
3271
3987
|
}
|
|
3988
|
+
// Out of effect; its versions stay as history.
|
|
3989
|
+
for (const version of this.triggerVersions.get(triggerId) ?? []) {
|
|
3990
|
+
version.status = version.status === 'live' ? 'superseded' : version.status === 'candidate' ? 'discarded' : version.status;
|
|
3991
|
+
}
|
|
3272
3992
|
this.triggers.delete(triggerId);
|
|
3273
3993
|
return { status: 204 };
|
|
3274
3994
|
}
|
|
@@ -3278,7 +3998,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3278
3998
|
const request = (body ?? {});
|
|
3279
3999
|
const schedule = request.source === 'schedule';
|
|
3280
4000
|
const errors = {};
|
|
3281
|
-
if (!dashCase$
|
|
4001
|
+
if (!dashCase$5.test(triggerId)) {
|
|
3282
4002
|
errors['triggerId'] = ['invalid'];
|
|
3283
4003
|
}
|
|
3284
4004
|
if (request.source !== 'event' && request.source !== 'schedule') {
|
|
@@ -3293,16 +4013,16 @@ class NhAssistantMockRunConfiguration {
|
|
|
3293
4013
|
if (request.source === 'event') {
|
|
3294
4014
|
this.requireKnown(errors, 'event', request.event, this.catalog.triggerEvents.map(item => item.name), true);
|
|
3295
4015
|
// 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;
|
|
4016
|
+
const subjectType = this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.subjectType;
|
|
3297
4017
|
if (!errors['event'] && subjectType && !mockWorksOnCovers(agent, subjectType)) {
|
|
3298
4018
|
errors['event'] = ['invalid'];
|
|
3299
4019
|
}
|
|
3300
4020
|
}
|
|
3301
4021
|
if (schedule) {
|
|
3302
|
-
if ((text(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
|
|
3303
|
-
errors['cron'] = [text(request.cron) === null ? 'required' : 'invalid'];
|
|
4022
|
+
if ((text$1(request.cron) ?? '').split(' ').filter(field => field.length > 0).length < 5) {
|
|
4023
|
+
errors['cron'] = [text$1(request.cron) === null ? 'required' : 'invalid'];
|
|
3304
4024
|
}
|
|
3305
|
-
if (text(request.subjectSet) !== null) {
|
|
4025
|
+
if (text$1(request.subjectSet) !== null) {
|
|
3306
4026
|
this.requireKnown(errors, 'subjectSet', request.subjectSet, this.catalog.subjectSets.map(item => item.name));
|
|
3307
4027
|
}
|
|
3308
4028
|
else if (!mockWorksOnCovers(agent, mockSystemSubjectType)) {
|
|
@@ -3310,7 +4030,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3310
4030
|
// that works on it may do.
|
|
3311
4031
|
errors['subjectSet'] = ['required'];
|
|
3312
4032
|
}
|
|
3313
|
-
const message = text(request.message);
|
|
4033
|
+
const message = text$1(request.message);
|
|
3314
4034
|
if (message === null) {
|
|
3315
4035
|
errors['message'] = ['required'];
|
|
3316
4036
|
}
|
|
@@ -3318,7 +4038,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3318
4038
|
errors['message'] = ['too-long'];
|
|
3319
4039
|
}
|
|
3320
4040
|
}
|
|
3321
|
-
const skill = text(request.skill);
|
|
4041
|
+
const skill = text$1(request.skill);
|
|
3322
4042
|
if (skill !== null && !this.skillIds(agent).includes(skill)) {
|
|
3323
4043
|
errors['skill'] = ['not-found'];
|
|
3324
4044
|
}
|
|
@@ -3327,11 +4047,16 @@ class NhAssistantMockRunConfiguration {
|
|
|
3327
4047
|
if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
|
|
3328
4048
|
errors['filters'] = ['invalid'];
|
|
3329
4049
|
}
|
|
4050
|
+
// A new trigger has no live version to evaluate a candidate next to.
|
|
4051
|
+
if ((request.apply !== undefined && request.apply !== 'live' && request.apply !== 'shadow')
|
|
4052
|
+
|| (request.apply === 'shadow' && (request.expectedVersion ?? null) === null)) {
|
|
4053
|
+
errors['apply'] = ['invalid'];
|
|
4054
|
+
}
|
|
3330
4055
|
if (Object.keys(errors).length > 0) {
|
|
3331
4056
|
return mockFailure('assistant-validation', errors);
|
|
3332
4057
|
}
|
|
3333
4058
|
if (request.source === 'event') {
|
|
3334
|
-
const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
|
|
4059
|
+
const offered = (this.catalog.triggerEvents.find(item => item.name === text$1(request.event))?.filters ?? []).map(filter => filter.name);
|
|
3335
4060
|
if (filters.some(name => !offered.includes(name))) {
|
|
3336
4061
|
return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
|
|
3337
4062
|
}
|
|
@@ -3339,34 +4064,139 @@ class NhAssistantMockRunConfiguration {
|
|
|
3339
4064
|
if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
|
|
3340
4065
|
return mockFailure('assistant-version-conflict');
|
|
3341
4066
|
}
|
|
4067
|
+
const candidate = request.apply === 'shadow';
|
|
4068
|
+
const history = this.triggerVersions.get(triggerId) ?? [];
|
|
3342
4069
|
const saved = {
|
|
3343
4070
|
triggerId,
|
|
3344
4071
|
skillId: skill,
|
|
3345
4072
|
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,
|
|
4073
|
+
event: schedule ? null : text$1(request.event),
|
|
4074
|
+
cron: schedule ? text$1(request.cron) : null,
|
|
4075
|
+
timeZone: schedule ? text$1(request.timeZone) : null,
|
|
4076
|
+
subjectSet: schedule ? text$1(request.subjectSet) : null,
|
|
4077
|
+
message: schedule ? text$1(request.message) : null,
|
|
3351
4078
|
conflict: request.conflict ?? (schedule ? 'ignore' : 'coalesce'),
|
|
3352
4079
|
debounceSeconds: schedule ? 0 : request.debounceSeconds ?? 120,
|
|
3353
|
-
|
|
3354
|
-
|
|
4080
|
+
// A candidate always fires in shadow; a new version keeps the trigger's mode.
|
|
4081
|
+
shadow: candidate || (existing?.shadow ?? true),
|
|
4082
|
+
version: history.reduce((latest, version) => Math.max(latest, version.version), existing?.version ?? 0) + 1,
|
|
3355
4083
|
changedAt: this.now(),
|
|
3356
4084
|
changedBy: 'current-user',
|
|
3357
|
-
reason: text(request.reason),
|
|
4085
|
+
reason: text$1(request.reason),
|
|
3358
4086
|
inEffect: true,
|
|
3359
4087
|
problem: null,
|
|
3360
|
-
filters: schedule ? [] : filters
|
|
4088
|
+
filters: schedule ? [] : filters,
|
|
4089
|
+
createdAt: this.now(),
|
|
4090
|
+
createdBy: 'current-user'
|
|
3361
4091
|
};
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
4092
|
+
for (const version of history) {
|
|
4093
|
+
if (version.status === 'candidate') {
|
|
4094
|
+
version.status = 'discarded';
|
|
4095
|
+
}
|
|
4096
|
+
else if (version.status === 'live' && !candidate) {
|
|
4097
|
+
version.status = 'superseded';
|
|
4098
|
+
}
|
|
4099
|
+
}
|
|
4100
|
+
if (existing && history.length === 0) {
|
|
4101
|
+
// A trigger of the scenario's preset starts its history with its live version.
|
|
4102
|
+
history.push({ ...withoutOwner(existing), status: candidate ? 'live' : 'superseded', agentId: agent.id });
|
|
4103
|
+
}
|
|
4104
|
+
this.triggerVersions.set(triggerId, [...history, { ...withoutOwner(saved), status: candidate ? 'candidate' : 'live', agentId: agent.id }]);
|
|
4105
|
+
if (candidate) {
|
|
4106
|
+
return { status: 200, body: this.candidateDto(agent, this.candidateOf(triggerId)) };
|
|
4107
|
+
}
|
|
4108
|
+
this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
|
|
4109
|
+
return { status: 200, body: withoutOwner(saved) };
|
|
4110
|
+
}
|
|
4111
|
+
/** `admin/agents/{id}/triggers/{triggerId}/candidate/{promote|discard|comparison}`. */
|
|
4112
|
+
triggerCandidate(method, agent, triggerId, action, body) {
|
|
4113
|
+
const live = this.triggers.get(triggerId);
|
|
4114
|
+
const candidate = live && this.ownerOf(live) === agent.id ? this.candidateOf(triggerId) : undefined;
|
|
4115
|
+
if (action === 'comparison' && method === 'GET') {
|
|
4116
|
+
if (!candidate || !live) {
|
|
4117
|
+
return mockFailure('assistant-trigger-candidate-not-found');
|
|
4118
|
+
}
|
|
4119
|
+
const comparison = {
|
|
4120
|
+
agentId: agent.id,
|
|
4121
|
+
triggerId,
|
|
4122
|
+
from: candidate.createdAt ?? candidate.changedAt,
|
|
4123
|
+
to: this.now(),
|
|
4124
|
+
live: emptyStats(live.version),
|
|
4125
|
+
candidate: emptyStats(candidate.version),
|
|
4126
|
+
pairedEvents: 0,
|
|
4127
|
+
liveOnlyEvents: 0,
|
|
4128
|
+
candidateOnlyEvents: 0,
|
|
4129
|
+
tools: []
|
|
4130
|
+
};
|
|
4131
|
+
return { status: 200, body: comparison };
|
|
4132
|
+
}
|
|
4133
|
+
if ((action !== 'promote' && action !== 'discard') || method !== 'POST') {
|
|
4134
|
+
return { status: 405 };
|
|
4135
|
+
}
|
|
4136
|
+
const request = body;
|
|
4137
|
+
if (typeof request?.version !== 'number') {
|
|
4138
|
+
return mockFailure('assistant-validation', { version: ['required'] });
|
|
4139
|
+
}
|
|
4140
|
+
if (!candidate || !live) {
|
|
4141
|
+
return mockFailure('assistant-trigger-candidate-not-found');
|
|
4142
|
+
}
|
|
4143
|
+
if (candidate.version !== request.version) {
|
|
4144
|
+
return mockFailure('assistant-version-conflict');
|
|
4145
|
+
}
|
|
4146
|
+
if (action === 'discard') {
|
|
4147
|
+
candidate.status = 'discarded';
|
|
4148
|
+
return { status: 204 };
|
|
4149
|
+
}
|
|
4150
|
+
for (const version of this.triggerVersions.get(triggerId) ?? []) {
|
|
4151
|
+
if (version.status === 'live') {
|
|
4152
|
+
version.status = 'superseded';
|
|
4153
|
+
}
|
|
4154
|
+
}
|
|
4155
|
+
candidate.status = 'live';
|
|
4156
|
+
// The promoted version keeps working in the trigger's mode.
|
|
4157
|
+
const { status, agentId, ...definition } = candidate;
|
|
4158
|
+
const promoted = { ...definition, shadow: live.shadow, changedAt: this.now(), changedBy: 'current-user' };
|
|
4159
|
+
candidate.shadow = live.shadow;
|
|
4160
|
+
this.triggers.set(triggerId, Object.assign(promoted, { [ownerKey]: agent.id }));
|
|
4161
|
+
return { status: 200, body: withoutOwner(promoted) };
|
|
4162
|
+
}
|
|
4163
|
+
/** Every version of a data trigger of the agent, newest first, with its definition. */
|
|
4164
|
+
triggerHistory(agentId, triggerId) {
|
|
4165
|
+
return [...(this.triggerVersions.get(triggerId) ?? [])]
|
|
4166
|
+
.filter(version => version.agentId === agentId)
|
|
4167
|
+
.sort((left, right) => right.version - left.version)
|
|
4168
|
+
.map(version => ({
|
|
4169
|
+
version: version.version,
|
|
4170
|
+
status: version.status,
|
|
4171
|
+
skillId: version.skillId,
|
|
4172
|
+
source: version.source,
|
|
4173
|
+
event: version.event,
|
|
4174
|
+
filters: [...(version.filters ?? [])],
|
|
4175
|
+
cron: version.cron,
|
|
4176
|
+
timeZone: version.timeZone,
|
|
4177
|
+
subjectSet: version.subjectSet,
|
|
4178
|
+
message: version.message,
|
|
4179
|
+
conflict: version.conflict,
|
|
4180
|
+
debounceSeconds: version.debounceSeconds,
|
|
4181
|
+
createdAt: version.createdAt ?? version.changedAt,
|
|
4182
|
+
createdBy: version.createdBy ?? version.changedBy,
|
|
4183
|
+
reason: version.reason
|
|
4184
|
+
}));
|
|
4185
|
+
}
|
|
4186
|
+
candidateOf(triggerId) {
|
|
4187
|
+
return (this.triggerVersions.get(triggerId) ?? []).find(version => version.status === 'candidate');
|
|
4188
|
+
}
|
|
4189
|
+
/** A candidate as the server shows it: always in shadow, with its own problem. */
|
|
4190
|
+
candidateDto(agent, candidate) {
|
|
4191
|
+
const { status, agentId, ...definition } = candidate;
|
|
4192
|
+
const problem = this.triggerProblem(agent, definition);
|
|
4193
|
+
return { ...definition, shadow: true, inEffect: problem === null, problem };
|
|
4194
|
+
}
|
|
4195
|
+
triggerShadow(method, agent, triggerId, body) {
|
|
4196
|
+
if (method !== 'POST') {
|
|
4197
|
+
return { status: 405 };
|
|
4198
|
+
}
|
|
4199
|
+
const existing = this.triggers.get(triggerId);
|
|
3370
4200
|
if (!existing || this.ownerOf(existing) !== agent.id) {
|
|
3371
4201
|
return mockFailure('assistant-trigger-not-found');
|
|
3372
4202
|
}
|
|
@@ -3383,6 +4213,54 @@ class NhAssistantMockRunConfiguration {
|
|
|
3383
4213
|
existing.reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
3384
4214
|
return { status: 200, body: withoutOwner(existing) };
|
|
3385
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* The ids of the variant's data skills that would move to `live` and the failure of one that cannot,
|
|
4218
|
+
* as a promotion with `moveSkills` checks them before anything changes: a skill the live agent has
|
|
4219
|
+
* in code stays (`assistant-skill-in-code`).
|
|
4220
|
+
*/
|
|
4221
|
+
skillsToMove(variantId, live) {
|
|
4222
|
+
const skillIds = this.liveSkills(variantId).map(skill => skill.skillId);
|
|
4223
|
+
const inCode = (live.skills ?? []).some(skill => skillIds.includes(skill.skillId) && !this.skills.has(key$1(live.id, skill.skillId)));
|
|
4224
|
+
return { skillIds, failure: inCode ? mockFailure('assistant-skill-in-code') : null };
|
|
4225
|
+
}
|
|
4226
|
+
/**
|
|
4227
|
+
* Moves the variant's data skills to `live` as a promotion does: each becomes the next live version
|
|
4228
|
+
* of the live agent's skill with the same id, or a new skill, which starts in shadow. Answers the ids
|
|
4229
|
+
* that moved, in order, or the failure of the first save.
|
|
4230
|
+
*/
|
|
4231
|
+
moveSkills(variantId, live, reason) {
|
|
4232
|
+
const moved = [];
|
|
4233
|
+
for (const skill of this.liveSkills(variantId)) {
|
|
4234
|
+
const current = (this.skills.get(key$1(live.id, skill.skillId)) ?? []).find(version => version.status === 'live');
|
|
4235
|
+
const request = {
|
|
4236
|
+
description: skill.description,
|
|
4237
|
+
instructions: skill.instructionsAsset ? null : skill.instructions,
|
|
4238
|
+
tools: [...skill.tools],
|
|
4239
|
+
profile: skill.profile,
|
|
4240
|
+
ruleHandler: skill.ruleHandler,
|
|
4241
|
+
maxTokensPerRun: skill.maxTokensPerRun,
|
|
4242
|
+
dailyToolCalls: skill.dailyToolCalls,
|
|
4243
|
+
dailyTokens: skill.dailyTokens,
|
|
4244
|
+
approvalPolicy: skill.approvalPolicy,
|
|
4245
|
+
expectedVersion: current?.version ?? null,
|
|
4246
|
+
reason,
|
|
4247
|
+
...(current ? { apply: 'live' } : {}),
|
|
4248
|
+
dataClassification: skill.dataClassification ?? null,
|
|
4249
|
+
acceptsFiles: skill.acceptsFiles ?? false,
|
|
4250
|
+
maxFileBytes: skill.maxFileBytes ?? null,
|
|
4251
|
+
maxFilePages: skill.maxFilePages ?? null,
|
|
4252
|
+
maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
|
|
4253
|
+
maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null,
|
|
4254
|
+
instructionsAsset: skill.instructionsAsset ?? null
|
|
4255
|
+
};
|
|
4256
|
+
const saved = this.skill('PUT', live, skill.skillId, request, new URLSearchParams());
|
|
4257
|
+
if (saved.status !== 200) {
|
|
4258
|
+
return { moved, failure: saved };
|
|
4259
|
+
}
|
|
4260
|
+
moved.push(skill.skillId);
|
|
4261
|
+
}
|
|
4262
|
+
return { moved, failure: null };
|
|
4263
|
+
}
|
|
3386
4264
|
/** An agent in code has a run configuration; one created in the administration once it has run settings. */
|
|
3387
4265
|
isConfigured(agent) {
|
|
3388
4266
|
return agent.source === 'code' || this.settings.has(agent.id);
|
|
@@ -3413,8 +4291,11 @@ class NhAssistantMockRunConfiguration {
|
|
|
3413
4291
|
* that its profile, or else its agent's, does not permit.
|
|
3414
4292
|
*/
|
|
3415
4293
|
withWarnings(agent, skill) {
|
|
4294
|
+
// A version whose asset no longer resolves to its hash is out of effect, as after the server's load.
|
|
4295
|
+
const assetProblem = skill.instructionsAsset ? this.instructionAssets.problemOf(skill.instructionsAsset) : null;
|
|
3416
4296
|
return {
|
|
3417
4297
|
...skill,
|
|
4298
|
+
...(assetProblem !== null ? { inEffect: false, problem: assetProblem } : {}),
|
|
3418
4299
|
warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
|
|
3419
4300
|
};
|
|
3420
4301
|
}
|
|
@@ -3422,7 +4303,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
3422
4303
|
return trigger[ownerKey];
|
|
3423
4304
|
}
|
|
3424
4305
|
requireKnown(errors, field, value, known, required = false) {
|
|
3425
|
-
const name = text(value);
|
|
4306
|
+
const name = text$1(value);
|
|
3426
4307
|
if (name === null) {
|
|
3427
4308
|
if (required) {
|
|
3428
4309
|
errors[field] = ['required'];
|
|
@@ -3470,7 +4351,7 @@ function withoutOwner(trigger) {
|
|
|
3470
4351
|
function emptyStats(version) {
|
|
3471
4352
|
return { version, runs: 0, completed: 0, failed: 0, proposals: 0, toolCalls: 0, tokens: 0 };
|
|
3472
4353
|
}
|
|
3473
|
-
function text(value) {
|
|
4354
|
+
function text$1(value) {
|
|
3474
4355
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
3475
4356
|
}
|
|
3476
4357
|
function inRange(value, min, max) {
|
|
@@ -3527,20 +4408,29 @@ class NhAssistantMockAdmin {
|
|
|
3527
4408
|
this.canAdminister = admin.canAdminister ?? true;
|
|
3528
4409
|
this.preferences = { ...defaultPreferences, ...scenario.preferences };
|
|
3529
4410
|
this.contextVersions.push({ version: 1, hash: mockHashText(admin.context ?? ''), updatedAt: now(), updatedBy: null, text: admin.context ?? '' });
|
|
4411
|
+
this.instructionAssets = new NhAssistantMockInstructionAssets(admin.instructionAssets);
|
|
3530
4412
|
this.agents = new NhAssistantMockAdminAgents(scenario, now, {
|
|
3531
4413
|
hasServer: serverId => this.mcp.hasServer(serverId),
|
|
3532
4414
|
agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
|
|
3533
4415
|
divisionsListed: () => this.runConfiguration.divisionsListed,
|
|
3534
4416
|
profileNames: () => this.runConfiguration.profileNames,
|
|
3535
|
-
dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
|
|
4417
|
+
dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId),
|
|
4418
|
+
shadowVariantRefusal: input => this.handOffs.shadowVariantRefusal(input),
|
|
4419
|
+
instructionAssets: () => this.instructionAssets
|
|
3536
4420
|
});
|
|
3537
4421
|
this.accounts = new NhAssistantMockAccounts(admin);
|
|
3538
4422
|
this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
|
|
3539
4423
|
find: agentId => this.agents.find(agentId),
|
|
3540
4424
|
all: () => this.agents.all(),
|
|
3541
4425
|
knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
|
|
3542
|
-
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
|
|
3543
|
-
|
|
4426
|
+
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff),
|
|
4427
|
+
acceptsDataHandOffs: agentId => this.handOffs.acceptsDataHandOffs(agentId)
|
|
4428
|
+
}, now, this.accounts, this.instructionAssets);
|
|
4429
|
+
this.handOffs = new NhAssistantMockHandOffs(admin, {
|
|
4430
|
+
find: agentId => this.agents.find(agentId),
|
|
4431
|
+
all: () => this.agents.all(),
|
|
4432
|
+
isConfigured: agent => this.runConfiguration.isConfigured(agent)
|
|
4433
|
+
}, now);
|
|
3544
4434
|
this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
|
|
3545
4435
|
agents: () => this.agents.all(),
|
|
3546
4436
|
chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
|
|
@@ -3593,6 +4483,30 @@ class NhAssistantMockAdmin {
|
|
|
3593
4483
|
allAgents() {
|
|
3594
4484
|
return this.agents.all();
|
|
3595
4485
|
}
|
|
4486
|
+
/** One agent of the administration, or `undefined`. */
|
|
4487
|
+
findAgent(agentId) {
|
|
4488
|
+
return this.agents.find(agentId);
|
|
4489
|
+
}
|
|
4490
|
+
/** The definition in code of an agent in code; `null` for an agent created here. */
|
|
4491
|
+
codeDefinition(agentId) {
|
|
4492
|
+
return this.agents.codeDefinition(agentId);
|
|
4493
|
+
}
|
|
4494
|
+
/**
|
|
4495
|
+
* A copy of the agents and their run configuration, consultations and hand-offs; the returned
|
|
4496
|
+
* function puts it back, for an import of an agent package that fails as a whole and for its preview.
|
|
4497
|
+
*/
|
|
4498
|
+
snapshot() {
|
|
4499
|
+
const restores = [this.agents.snapshot(), this.runConfiguration.snapshot(), this.handOffs.snapshot()];
|
|
4500
|
+
return () => {
|
|
4501
|
+
for (const restore of restores) {
|
|
4502
|
+
restore();
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
}
|
|
4506
|
+
/** Makes the next version of an agent, as a change of its metadata does. */
|
|
4507
|
+
nextAgentVersion(agentId) {
|
|
4508
|
+
return this.agents.nextVersion(agentId);
|
|
4509
|
+
}
|
|
3596
4510
|
handlePreferences(method, body) {
|
|
3597
4511
|
if (method === 'GET') {
|
|
3598
4512
|
return { status: 200, body: this.preferences };
|
|
@@ -3635,6 +4549,8 @@ class NhAssistantMockAdmin {
|
|
|
3635
4549
|
return method === 'GET' && segments.length === 2 ? { status: 200, body: this.toolCatalog() } : mockFailure('assistant-not-found');
|
|
3636
4550
|
case 'run-catalog':
|
|
3637
4551
|
return segments.length === 2 ? this.runConfiguration.runCatalog(method) : mockFailure('assistant-not-found');
|
|
4552
|
+
case 'instruction-assets':
|
|
4553
|
+
return this.instructionAssets.handle(method, segments.slice(2));
|
|
3638
4554
|
case 'accounts':
|
|
3639
4555
|
return segments.length === 2 ? this.accounts.search(method, query) : mockFailure('assistant-not-found');
|
|
3640
4556
|
case 'agents':
|
|
@@ -3647,6 +4563,15 @@ class NhAssistantMockAdmin {
|
|
|
3647
4563
|
if (id !== undefined && (action === 'replay' || action === 'replays') && segments.length === 4) {
|
|
3648
4564
|
return this.agents.agentReplay(method, id, action, body);
|
|
3649
4565
|
}
|
|
4566
|
+
if (id !== undefined && action === 'promote' && segments.length === 4) {
|
|
4567
|
+
return this.promote(method, id, body);
|
|
4568
|
+
}
|
|
4569
|
+
if (id !== undefined && (action === 'hand-offs' || action === 'shadow-variants')) {
|
|
4570
|
+
const handed = this.handOffs.handle(method, id, segments.slice(3), body, query);
|
|
4571
|
+
if (handed) {
|
|
4572
|
+
return handed;
|
|
4573
|
+
}
|
|
4574
|
+
}
|
|
3650
4575
|
if (id !== undefined && action !== undefined) {
|
|
3651
4576
|
const configured = this.runConfiguration.handle(method, id, segments.slice(3), body, query);
|
|
3652
4577
|
if (configured) {
|
|
@@ -3668,6 +4593,56 @@ class NhAssistantMockAdmin {
|
|
|
3668
4593
|
return mockFailure('assistant-not-found');
|
|
3669
4594
|
}
|
|
3670
4595
|
}
|
|
4596
|
+
/**
|
|
4597
|
+
* `POST admin/agents/{id}/promote`: a data shadow variant's definition becomes its live agent's next
|
|
4598
|
+
* version, its skills move along with `moveSkills`, and the variant is deleted, as on the server. A
|
|
4599
|
+
* stale version of either agent answers `409`; a skill the live agent has in code stops the promotion
|
|
4600
|
+
* before anything changes.
|
|
4601
|
+
*/
|
|
4602
|
+
promote(method, variantId, body) {
|
|
4603
|
+
if (method !== 'POST') {
|
|
4604
|
+
return { status: 405 };
|
|
4605
|
+
}
|
|
4606
|
+
const variant = this.agents.find(variantId);
|
|
4607
|
+
if (!variant) {
|
|
4608
|
+
return mockFailure('assistant-agent-not-found');
|
|
4609
|
+
}
|
|
4610
|
+
if (variant.source !== 'admin' || !variant.shadowOf) {
|
|
4611
|
+
return mockFailure('assistant-not-shadow-variant');
|
|
4612
|
+
}
|
|
4613
|
+
const request = body;
|
|
4614
|
+
if (typeof request?.expectedVersion !== 'number') {
|
|
4615
|
+
return mockFailure('assistant-validation', { expectedVersion: ['required'] });
|
|
4616
|
+
}
|
|
4617
|
+
if (typeof request.reason === 'string' && request.reason.length > 500) {
|
|
4618
|
+
return mockFailure('assistant-validation', { reason: ['too-long'] });
|
|
4619
|
+
}
|
|
4620
|
+
const live = this.agents.find(variant.shadowOf);
|
|
4621
|
+
if (!live) {
|
|
4622
|
+
return mockFailure('assistant-agent-not-found');
|
|
4623
|
+
}
|
|
4624
|
+
if (request.expectedVersion !== live.version
|
|
4625
|
+
|| (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
|
|
4626
|
+
return mockFailure('assistant-version-conflict');
|
|
4627
|
+
}
|
|
4628
|
+
if (request.moveSkills === true) {
|
|
4629
|
+
const refused = this.runConfiguration.skillsToMove(variant.id, live).failure;
|
|
4630
|
+
if (refused) {
|
|
4631
|
+
return refused;
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
// In the server's order: the definition, the skills, then the variant leaves.
|
|
4635
|
+
const agent = this.agents.promote(variant.id, live.id);
|
|
4636
|
+
const moved = request.moveSkills === true
|
|
4637
|
+
? this.runConfiguration.moveSkills(variant.id, agent, typeof request.reason === 'string' ? request.reason.trim() || null : null)
|
|
4638
|
+
: { moved: [], failure: null };
|
|
4639
|
+
if (moved.failure) {
|
|
4640
|
+
return moved.failure;
|
|
4641
|
+
}
|
|
4642
|
+
this.agents.agentsEndpoint('DELETE', variant.id, undefined, undefined, 3);
|
|
4643
|
+
const promotion = { agent: this.agents.find(agent.id), variantId: variant.id, movedSkills: moved.moved };
|
|
4644
|
+
return { status: 200, body: promotion };
|
|
4645
|
+
}
|
|
3671
4646
|
context(method, sub, body) {
|
|
3672
4647
|
const current = this.contextVersions[this.contextVersions.length - 1];
|
|
3673
4648
|
if (sub === 'versions' && method === 'GET') {
|
|
@@ -3726,33 +4701,1356 @@ class NhAssistantMockAdmin {
|
|
|
3726
4701
|
}
|
|
3727
4702
|
}
|
|
3728
4703
|
}
|
|
3729
|
-
for (const agent of agents) {
|
|
3730
|
-
for (const { subjectType, ...consultation } of agent.dataConsultations ?? []) {
|
|
3731
|
-
presetStep(agent.id, `consultation about '${subjectType}'`, this.runConfiguration.handle('PUT', agent.id, ['consultations', subjectType], consultation, query));
|
|
3732
|
-
}
|
|
4704
|
+
for (const agent of agents) {
|
|
4705
|
+
for (const { subjectType, ...consultation } of agent.dataConsultations ?? []) {
|
|
4706
|
+
presetStep(agent.id, `consultation about '${subjectType}'`, this.runConfiguration.handle('PUT', agent.id, ['consultations', subjectType], consultation, query));
|
|
4707
|
+
}
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
/**
|
|
4711
|
+
* The scenario's tools, with the object type they name as the server reports it, the generated tools
|
|
4712
|
+
* of the consultations defined as data in effect, read live as the server reads its catalogs, and
|
|
4713
|
+
* the enabled MCP tools.
|
|
4714
|
+
*/
|
|
4715
|
+
toolCatalog() {
|
|
4716
|
+
return [
|
|
4717
|
+
...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
|
|
4718
|
+
...this.runConfiguration.consultationTools(),
|
|
4719
|
+
...this.mcp.catalogEntries()
|
|
4720
|
+
];
|
|
4721
|
+
}
|
|
4722
|
+
}
|
|
4723
|
+
/** Fails the scenario when the mock refused a part of a preset run configuration, with the mock's answer. */
|
|
4724
|
+
function presetStep(agentId, part, result) {
|
|
4725
|
+
if (!result || result.status >= 300) {
|
|
4726
|
+
throw new Error(`The mock refused the ${part} of agent '${agentId}' in the scenario's run configuration: ${JSON.stringify(result?.body ?? null)}.`);
|
|
4727
|
+
}
|
|
4728
|
+
}
|
|
4729
|
+
function toContext(version) {
|
|
4730
|
+
return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4733
|
+
/** The longest package id. */
|
|
4734
|
+
const maxPackageId = 128;
|
|
4735
|
+
/** The longest package description. */
|
|
4736
|
+
const maxPackageDescription = 500;
|
|
4737
|
+
/** The header of the export that lists the references to agents outside the selection. */
|
|
4738
|
+
const warningsHeader = 'X-NewHeap-Package-Warnings';
|
|
4739
|
+
/** The order in which an import writes the parts of its agents. */
|
|
4740
|
+
const phaseOrder = ['agent', 'runSettings', 'skill', 'trigger', 'consultation', 'handOff', 'metadata'];
|
|
4741
|
+
/** The properties of the package format each lockable field stands for. */
|
|
4742
|
+
const lockProperties = {
|
|
4743
|
+
agent: {
|
|
4744
|
+
instructions: ['instructions', 'instructionsAsset'],
|
|
4745
|
+
worksOn: ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy']
|
|
4746
|
+
},
|
|
4747
|
+
runSettings: {
|
|
4748
|
+
dailyBudget: ['dailyToolCalls', 'dailyTokens'],
|
|
4749
|
+
files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
|
|
4750
|
+
},
|
|
4751
|
+
skill: {
|
|
4752
|
+
instructions: ['instructions', 'instructionsAsset'],
|
|
4753
|
+
dailyBudget: ['dailyToolCalls', 'dailyTokens'],
|
|
4754
|
+
files: ['acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn']
|
|
4755
|
+
},
|
|
4756
|
+
trigger: {},
|
|
4757
|
+
consultation: {
|
|
4758
|
+
limits: ['maxToolCalls', 'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages']
|
|
4759
|
+
},
|
|
4760
|
+
handOff: {},
|
|
4761
|
+
metadata: {}
|
|
4762
|
+
};
|
|
4763
|
+
/** The fields of the definition an administrator overrides on an agent in code, and its overlay carries. */
|
|
4764
|
+
const codeAgentFields = ['displayName', 'description', 'instructions', 'toolSelectors', 'requiredPolicy', 'autonomy'];
|
|
4765
|
+
/** The properties the strict format reads per object, required ones first. */
|
|
4766
|
+
const formatShapes = {
|
|
4767
|
+
root: { required: ['format', 'formatVersion', 'package', 'agents'], optional: [] },
|
|
4768
|
+
package: { required: ['id', 'version'], optional: ['description'] },
|
|
4769
|
+
agent: {
|
|
4770
|
+
required: ['id', 'source'],
|
|
4771
|
+
optional: ['definition', 'runSettings', 'skills', 'triggers', 'consultations', 'handOffs', 'metadata', 'groups', 'locks', 'hashes']
|
|
4772
|
+
},
|
|
4773
|
+
definition: {
|
|
4774
|
+
required: [],
|
|
4775
|
+
optional: [
|
|
4776
|
+
'displayName', 'description', 'instructions', 'instructionsAsset', 'toolSelectors', 'requiredPolicy', 'autonomy', 'offeredInChat',
|
|
4777
|
+
'worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy', 'profile',
|
|
4778
|
+
'dataClassification', 'startsOff', 'startsInShadow', 'shadowOf'
|
|
4779
|
+
]
|
|
4780
|
+
},
|
|
4781
|
+
runSettings: {
|
|
4782
|
+
required: ['accountability'],
|
|
4783
|
+
optional: [
|
|
4784
|
+
'delegateFromInitiator', 'allowGatewayReads', 'elevatedReads', 'approvalPolicy', 'retryCount', 'dailyToolCalls', 'dailyTokens',
|
|
4785
|
+
'maxConcurrentRuns', 'maxRunsPerSubjectPerDay', 'maxTokensPerRun', 'triageProfile', 'ruleHandler', 'acceptsFiles', 'maxFileBytes',
|
|
4786
|
+
'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'
|
|
4787
|
+
]
|
|
4788
|
+
},
|
|
4789
|
+
skill: {
|
|
4790
|
+
required: ['id', 'description'],
|
|
4791
|
+
optional: [
|
|
4792
|
+
'instructions', 'instructionsAsset', 'tools', 'profile', 'ruleHandler', 'maxTokensPerRun', 'dailyToolCalls', 'dailyTokens',
|
|
4793
|
+
'approvalPolicy', 'dataClassification', 'acceptsFiles', 'maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn',
|
|
4794
|
+
'startsOff'
|
|
4795
|
+
]
|
|
4796
|
+
},
|
|
4797
|
+
trigger: {
|
|
4798
|
+
required: ['id', 'source'],
|
|
4799
|
+
optional: ['skill', 'event', 'filters', 'cron', 'timeZone', 'subjectSet', 'message', 'conflict', 'debounceSeconds']
|
|
4800
|
+
},
|
|
4801
|
+
consultation: {
|
|
4802
|
+
required: ['subjectType', 'description'],
|
|
4803
|
+
optional: [
|
|
4804
|
+
'instructions', 'minimumRole', 'askerPolicy', 'routes', 'proposalRole', 'proposalPolicy', 'toolSelectors', 'maxToolCalls',
|
|
4805
|
+
'timeoutSeconds', 'maxAnswerChars', 'maxHistoryMessages', 'allowRequests', 'exposeToMcp', 'dataClassification', 'startsOff'
|
|
4806
|
+
]
|
|
4807
|
+
},
|
|
4808
|
+
handOff: { required: ['subjectType', 'sources'], optional: ['description', 'notifySource', 'maxPerSubjectPerDay'] },
|
|
4809
|
+
instructionsAsset: { required: ['assetId', 'version', 'hash'], optional: [] }
|
|
4810
|
+
};
|
|
4811
|
+
/**
|
|
4812
|
+
* Agent packages of the mock API (S7-2), with the rules of the server: an export of exactly the chosen
|
|
4813
|
+
* agents with the references to agents outside them, a preview per part (`created`, `changed`,
|
|
4814
|
+
* `unchanged`, `kept-local`, `left-behind`, `refused`) after one check of the whole package against
|
|
4815
|
+
* the host as it would be after the import, and an import that applies every part through the mock's
|
|
4816
|
+
* own endpoints in dependency order, or nothing: a refused part answers `409 assistant-package-refused`
|
|
4817
|
+
* with the plain field error `parts: invalid`, and the preview says why. Linked parts carry their link
|
|
4818
|
+
* in every read; every save path refuses a change of a locked field with `{field}: locked`, and removing
|
|
4819
|
+
* a locked part or deleting or resetting a linked agent answers `409 assistant-package-locked`.
|
|
4820
|
+
* Unlinking makes a part local: the next upgrade keeps it as `kept-local` unless the import relinks it.
|
|
4821
|
+
* An import takes over an agent the host has with a package agent's id and no package of its own when
|
|
4822
|
+
* `relink` names that whole agent; otherwise the agent refuses the package (`assistant-agent-exists`).
|
|
4823
|
+
* Hashes are the SHA-256 of each part's canonical JSON; a hash a package gives is not checked.
|
|
4824
|
+
*/
|
|
4825
|
+
class NhAssistantMockAgentPackages {
|
|
4826
|
+
constructor(context) {
|
|
4827
|
+
this.context = context;
|
|
4828
|
+
this.links = new Map();
|
|
4829
|
+
this.installed = new Map();
|
|
4830
|
+
}
|
|
4831
|
+
/**
|
|
4832
|
+
* Imports the scenario's packages in order, as an administrator would; a package the import refuses
|
|
4833
|
+
* fails the scenario at once.
|
|
4834
|
+
*/
|
|
4835
|
+
importPresets(presets) {
|
|
4836
|
+
for (const preset of presets) {
|
|
4837
|
+
const result = this.importPackage({ package: preset.package, options: { serviceAccounts: preset.serviceAccounts ?? null } }, true);
|
|
4838
|
+
if (result.status !== 200) {
|
|
4839
|
+
throw new Error(`The mock refused the scenario's agent package '${preset.package?.package?.id}': ${JSON.stringify(result.body ?? null)}.`);
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4843
|
+
/**
|
|
4844
|
+
* `POST admin/agent-packages/export`, `.../preview` and `.../import`, and `POST
|
|
4845
|
+
* admin/agents/{id}/package/unlink`; `null` for another route.
|
|
4846
|
+
*/
|
|
4847
|
+
handle(method, segments, body) {
|
|
4848
|
+
if (segments[0] !== 'admin') {
|
|
4849
|
+
return null;
|
|
4850
|
+
}
|
|
4851
|
+
if (segments[1] === 'agent-packages' && segments.length === 3) {
|
|
4852
|
+
if (method !== 'POST') {
|
|
4853
|
+
return { status: 405 };
|
|
4854
|
+
}
|
|
4855
|
+
switch (segments[2]) {
|
|
4856
|
+
case 'export':
|
|
4857
|
+
return this.exportPackage(body);
|
|
4858
|
+
case 'preview':
|
|
4859
|
+
return this.importPackage(body, false);
|
|
4860
|
+
case 'import':
|
|
4861
|
+
return this.importPackage(body, true);
|
|
4862
|
+
default:
|
|
4863
|
+
return mockFailure('assistant-not-found');
|
|
4864
|
+
}
|
|
4865
|
+
}
|
|
4866
|
+
if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
|
|
4867
|
+
return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
|
|
4868
|
+
}
|
|
4869
|
+
return null;
|
|
4870
|
+
}
|
|
4871
|
+
/**
|
|
4872
|
+
* Runs a save of the administration under the package locks: a save may keep a locked field's value
|
|
4873
|
+
* or take the package's, never another; such a change is undone and refused with `{field}: locked`.
|
|
4874
|
+
* Removing a part with locks and deleting a linked agent answer `409 assistant-package-locked`, as
|
|
4875
|
+
* does a reset that would give a locked field of the definition another value; removing a linked
|
|
4876
|
+
* part without locks drops its link. Other requests run as they are.
|
|
4877
|
+
*/
|
|
4878
|
+
guard(method, segments, run) {
|
|
4879
|
+
const target = guardedPart(method, segments);
|
|
4880
|
+
if (!target) {
|
|
4881
|
+
return run();
|
|
4882
|
+
}
|
|
4883
|
+
if (target.part === null) {
|
|
4884
|
+
return this.isLinked(target.agentId) ? mockFailure('assistant-package-locked') : run();
|
|
4885
|
+
}
|
|
4886
|
+
const reset = target.reset === true;
|
|
4887
|
+
const link = this.links.get(target.agentId)?.parts.get(target.part);
|
|
4888
|
+
if (!link) {
|
|
4889
|
+
return run();
|
|
4890
|
+
}
|
|
4891
|
+
if (target.removal) {
|
|
4892
|
+
if (link.lockedFields.length > 0) {
|
|
4893
|
+
return mockFailure('assistant-package-locked');
|
|
4894
|
+
}
|
|
4895
|
+
const removed = run();
|
|
4896
|
+
if (removed.status < 300) {
|
|
4897
|
+
this.links.get(target.agentId)?.parts.delete(target.part);
|
|
4898
|
+
}
|
|
4899
|
+
return removed;
|
|
4900
|
+
}
|
|
4901
|
+
if (link.lockedFields.length === 0) {
|
|
4902
|
+
return run();
|
|
4903
|
+
}
|
|
4904
|
+
const kind = kindOf(target.part);
|
|
4905
|
+
const before = this.lockView(target.agentId, target.part);
|
|
4906
|
+
const restore = this.context.snapshot();
|
|
4907
|
+
const result = run();
|
|
4908
|
+
if (result.status >= 300) {
|
|
4909
|
+
return result;
|
|
4910
|
+
}
|
|
4911
|
+
const changed = lockedChanges(kind, link.lockedFields, before, this.lockView(target.agentId, target.part), link.view);
|
|
4912
|
+
if (changed.length > 0) {
|
|
4913
|
+
restore();
|
|
4914
|
+
return reset ? mockFailure('assistant-package-locked') : mockFailure('assistant-validation', Object.fromEntries(changed.map(field => [field, ['locked']])));
|
|
4915
|
+
}
|
|
4916
|
+
return result;
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* Adds the package links to an administration answer: the agent's package on agents, the part's link
|
|
4920
|
+
* on run settings, skills, triggers, consultations, hand-offs and metadata entries.
|
|
4921
|
+
*/
|
|
4922
|
+
decorate(method, segments, result) {
|
|
4923
|
+
if (segments[0] !== 'admin' || segments[1] !== 'agents' || result.status >= 300 || result.body === undefined || this.links.size === 0) {
|
|
4924
|
+
return result;
|
|
4925
|
+
}
|
|
4926
|
+
const [, , agentId, area, id, action] = segments;
|
|
4927
|
+
const body = result.body;
|
|
4928
|
+
if (agentId === undefined) {
|
|
4929
|
+
if (Array.isArray(body)) {
|
|
4930
|
+
return { ...result, body: body.map(agent => this.withAgentPackage(agent)) };
|
|
4931
|
+
}
|
|
4932
|
+
return { ...result, body: this.withAgentPackage(body) };
|
|
4933
|
+
}
|
|
4934
|
+
if (area === undefined || (area === 'reset' && segments.length === 4)) {
|
|
4935
|
+
return { ...result, body: this.withAgentPackage(body) };
|
|
4936
|
+
}
|
|
4937
|
+
if (method === 'DELETE' && area !== 'metadata') {
|
|
4938
|
+
return result;
|
|
4939
|
+
}
|
|
4940
|
+
switch (area) {
|
|
4941
|
+
case 'configuration': {
|
|
4942
|
+
const configuration = body;
|
|
4943
|
+
return {
|
|
4944
|
+
...result,
|
|
4945
|
+
body: {
|
|
4946
|
+
...configuration,
|
|
4947
|
+
runSettings: configuration.runSettings ? this.withLink(agentId, 'runSettings', configuration.runSettings) : configuration.runSettings,
|
|
4948
|
+
dataSkills: configuration.dataSkills.map(skill => this.withLink(agentId, `skill:${skill.skillId}`, skill)),
|
|
4949
|
+
dataTriggers: configuration.dataTriggers.map(trigger => this.withLink(agentId, `trigger:${trigger.triggerId}`, trigger)),
|
|
4950
|
+
dataConsultations: configuration.dataConsultations?.map(item => this.withLink(agentId, `consultation:${item.subjectType}`, item))
|
|
4951
|
+
}
|
|
4952
|
+
};
|
|
4953
|
+
}
|
|
4954
|
+
case 'run-settings':
|
|
4955
|
+
return { ...result, body: this.withLink(agentId, 'runSettings', body) };
|
|
4956
|
+
case 'skills':
|
|
4957
|
+
return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote'))
|
|
4958
|
+
? { ...result, body: this.withLink(agentId, `skill:${id}`, body) }
|
|
4959
|
+
: result;
|
|
4960
|
+
case 'triggers':
|
|
4961
|
+
return id !== undefined && (segments.length === 5 || (action === 'candidate' && segments[6] === 'promote') || action === 'shadow')
|
|
4962
|
+
? { ...result, body: this.withLink(agentId, `trigger:${id}`, body) }
|
|
4963
|
+
: result;
|
|
4964
|
+
case 'consultations':
|
|
4965
|
+
return id !== undefined ? { ...result, body: this.withLink(agentId, `consultation:${id}`, body) } : result;
|
|
4966
|
+
case 'hand-offs': {
|
|
4967
|
+
if (id !== undefined) {
|
|
4968
|
+
return { ...result, body: this.withLink(agentId, `handOff:${id}`, body) };
|
|
4969
|
+
}
|
|
4970
|
+
const handOffs = body;
|
|
4971
|
+
return { ...result, body: { ...handOffs, dataHandOffs: handOffs.dataHandOffs.map(item => this.withLink(agentId, `handOff:${item.subjectType}`, item)) } };
|
|
4972
|
+
}
|
|
4973
|
+
case 'metadata':
|
|
4974
|
+
if (id !== undefined) {
|
|
4975
|
+
return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
|
|
4976
|
+
}
|
|
4977
|
+
return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
|
|
4978
|
+
default:
|
|
4979
|
+
return result;
|
|
4980
|
+
}
|
|
4981
|
+
}
|
|
4982
|
+
/** A copy of the links and the imported packages; the returned function puts them back. */
|
|
4983
|
+
snapshot() {
|
|
4984
|
+
return mockSnapshot(this.links, this.installed);
|
|
4985
|
+
}
|
|
4986
|
+
/** Forgets the links of an agent that was deleted, as the server removes them with it. */
|
|
4987
|
+
forget(agentId) {
|
|
4988
|
+
this.links.delete(agentId);
|
|
4989
|
+
}
|
|
4990
|
+
exportPackage(body) {
|
|
4991
|
+
const request = (body ?? {});
|
|
4992
|
+
const agentIds = request.agentIds;
|
|
4993
|
+
if (!Array.isArray(agentIds) || agentIds.length === 0 || !agentIds.every(id => typeof id === 'string')) {
|
|
4994
|
+
return mockFailure('assistant-validation', { agentIds: ['required'] });
|
|
4995
|
+
}
|
|
4996
|
+
if (agentIds.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
|
|
4997
|
+
return mockFailure('assistant-validation', { agentIds: ['too-long'] });
|
|
4998
|
+
}
|
|
4999
|
+
if (new Set(agentIds).size !== agentIds.length) {
|
|
5000
|
+
return mockFailure('assistant-validation', { agentIds: ['invalid'] });
|
|
5001
|
+
}
|
|
5002
|
+
const reads = [];
|
|
5003
|
+
for (const agentId of agentIds) {
|
|
5004
|
+
const read = this.read(agentId);
|
|
5005
|
+
if (!read) {
|
|
5006
|
+
return mockFailure('assistant-agent-not-found');
|
|
5007
|
+
}
|
|
5008
|
+
reads.push(read);
|
|
5009
|
+
}
|
|
5010
|
+
const options = request.options ?? {};
|
|
5011
|
+
const errors = {};
|
|
5012
|
+
const linked = this.links.get(reads[0].agent.id);
|
|
5013
|
+
const packageId = text(options.packageId) ?? linked?.packageId ?? reads[0].agent.id;
|
|
5014
|
+
if (!mockDashCase.test(packageId) || packageId.length > maxPackageId) {
|
|
5015
|
+
errors['packageId'] = ['invalid'];
|
|
5016
|
+
}
|
|
5017
|
+
const installed = this.installed.get(packageId);
|
|
5018
|
+
const version = options.version ?? (installed ? installed.version + 1 : 1);
|
|
5019
|
+
if (!Number.isInteger(version) || version < 1) {
|
|
5020
|
+
errors['version'] = ['invalid'];
|
|
5021
|
+
}
|
|
5022
|
+
const description = text(options.description);
|
|
5023
|
+
if (description !== null && description.length > maxPackageDescription) {
|
|
5024
|
+
errors['description'] = ['too-long'];
|
|
5025
|
+
}
|
|
5026
|
+
const agents = reads.map(read => this.packageAgentOf(read));
|
|
5027
|
+
for (const [agentId, locks] of Object.entries(options.locks ?? {})) {
|
|
5028
|
+
const agent = agents.find(item => item.id === agentId);
|
|
5029
|
+
for (const [part, fields] of Object.entries(locks ?? {})) {
|
|
5030
|
+
// A lock of an agent or part the export does not hold, or of a field its kind does not lock.
|
|
5031
|
+
const problem = agent ? lockProblem(agent, part, fields) : 'not-found';
|
|
5032
|
+
if (problem) {
|
|
5033
|
+
errors['locks'] = ['invalid'];
|
|
5034
|
+
}
|
|
5035
|
+
else if (agent && fields.length > 0) {
|
|
5036
|
+
agent.locks = { ...(agent.locks ?? {}), [part]: [...fields] };
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
}
|
|
5040
|
+
if (Object.keys(errors).length > 0) {
|
|
5041
|
+
return mockFailure('assistant-validation', errors);
|
|
5042
|
+
}
|
|
5043
|
+
for (const agent of agents) {
|
|
5044
|
+
agent.hashes = Object.fromEntries([...partsOf(agent)].map(([part, value]) => [part, mockSha256(canonicalJson(value))]));
|
|
5045
|
+
}
|
|
5046
|
+
const agentPackage = {
|
|
5047
|
+
format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
|
|
5048
|
+
formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
|
|
5049
|
+
package: { id: packageId, version, ...(description !== null ? { description } : {}) },
|
|
5050
|
+
agents
|
|
5051
|
+
};
|
|
5052
|
+
return {
|
|
5053
|
+
status: 200,
|
|
5054
|
+
text: JSON.stringify(withoutNulls(agentPackage), null, 2),
|
|
5055
|
+
headers: {
|
|
5056
|
+
'content-type': 'application/json',
|
|
5057
|
+
'content-disposition': `attachment; filename="agent-package-${packageId}.json"`,
|
|
5058
|
+
[warningsHeader]: JSON.stringify(this.warningsOf(reads))
|
|
5059
|
+
}
|
|
5060
|
+
};
|
|
5061
|
+
}
|
|
5062
|
+
/** Every reference of the exported agents to agents outside the selection. */
|
|
5063
|
+
warningsOf(reads) {
|
|
5064
|
+
const selected = new Set(reads.map(read => read.agent.id));
|
|
5065
|
+
const everyone = this.agents();
|
|
5066
|
+
const warnings = [];
|
|
5067
|
+
const outside = (agentId, part, code, referencedAgentId) => {
|
|
5068
|
+
if (!selected.has(referencedAgentId)) {
|
|
5069
|
+
warnings.push({ agentId, part, code, referencedAgentId });
|
|
5070
|
+
}
|
|
5071
|
+
};
|
|
5072
|
+
for (const { agent, configuration, handOffs } of reads) {
|
|
5073
|
+
if (agent.shadowOf) {
|
|
5074
|
+
outside(agent.id, 'agent', 'shadow-of', agent.shadowOf);
|
|
5075
|
+
}
|
|
5076
|
+
for (const variant of everyone.filter(item => item.shadowOf === agent.id)) {
|
|
5077
|
+
outside(agent.id, 'agent', 'shadow-variant', variant.id);
|
|
5078
|
+
}
|
|
5079
|
+
for (const handOff of handOffs?.dataHandOffs ?? []) {
|
|
5080
|
+
for (const source of handOff.sources) {
|
|
5081
|
+
outside(agent.id, `handOff:${handOff.subjectType}`, 'hand-off-source', source);
|
|
5082
|
+
}
|
|
5083
|
+
}
|
|
5084
|
+
for (const consultation of configuration?.dataConsultations ?? []) {
|
|
5085
|
+
for (const asker of consultation.askingAgents) {
|
|
5086
|
+
outside(agent.id, `consultation:${consultation.subjectType}`, 'consultation-asker', asker);
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
}
|
|
5090
|
+
return warnings;
|
|
5091
|
+
}
|
|
5092
|
+
/**
|
|
5093
|
+
* `POST admin/agent-packages/preview` (`keep` false) and `.../import`: the package read strictly, its
|
|
5094
|
+
* parts applied in dependency order on a copy of the host, and, for an import without a refused part,
|
|
5095
|
+
* applied for real with the links of its parts. An import with a refused part answers
|
|
5096
|
+
* `assistant-package-refused` with `parts: invalid`, as the server does; the preview names each reason.
|
|
5097
|
+
*/
|
|
5098
|
+
importPackage(body, keep) {
|
|
5099
|
+
const request = (body ?? {});
|
|
5100
|
+
const read = readPackage(request.package);
|
|
5101
|
+
if ('status' in read) {
|
|
5102
|
+
return read;
|
|
5103
|
+
}
|
|
5104
|
+
const agentPackage = read.package;
|
|
5105
|
+
const options = request.options ?? {};
|
|
5106
|
+
const apply = options.apply ?? 'live';
|
|
5107
|
+
if (apply !== 'live' && apply !== 'shadow') {
|
|
5108
|
+
return mockFailure('assistant-validation', { apply: ['invalid'] });
|
|
5109
|
+
}
|
|
5110
|
+
// The accounts and the parts to relink name agents of the package, as the server checks them.
|
|
5111
|
+
const ids = new Set(agentPackage.agents.map(agent => agent.id));
|
|
5112
|
+
const optionErrors = {};
|
|
5113
|
+
if (Object.entries(options.serviceAccounts ?? {}).some(([agentId, actorId]) => !ids.has(agentId) || typeof actorId !== 'string' || actorId.trim().length === 0 || actorId.trim().length > 256)) {
|
|
5114
|
+
optionErrors['serviceAccounts'] = ['invalid'];
|
|
5115
|
+
}
|
|
5116
|
+
if ((options.relink ?? []).some(item => !item || !ids.has(item.agentId)
|
|
5117
|
+
|| (item.part !== null && item.part !== undefined && (typeof item.part !== 'string' || !validPartKey(item.part))))) {
|
|
5118
|
+
optionErrors['relink'] = ['invalid'];
|
|
5119
|
+
}
|
|
5120
|
+
if (Object.keys(optionErrors).length > 0) {
|
|
5121
|
+
return mockFailure('assistant-validation', optionErrors);
|
|
5122
|
+
}
|
|
5123
|
+
// The check tries every part on a copy of the host, in live mode, and puts the host back.
|
|
5124
|
+
const restoreHost = this.context.snapshot();
|
|
5125
|
+
const restoreOwn = this.snapshot();
|
|
5126
|
+
const planned = this.apply(agentPackage, options, 'live', null);
|
|
5127
|
+
restoreHost();
|
|
5128
|
+
restoreOwn();
|
|
5129
|
+
const preview = {
|
|
5130
|
+
packageId: agentPackage.package.id,
|
|
5131
|
+
packageVersion: agentPackage.package.version,
|
|
5132
|
+
installedVersion: this.installed.get(agentPackage.package.id)?.version ?? null,
|
|
5133
|
+
importable: planned.every(part => part.outcome !== 'refused'),
|
|
5134
|
+
parts: planned.map(publicPart)
|
|
5135
|
+
};
|
|
5136
|
+
if (!keep) {
|
|
5137
|
+
return { status: 200, body: preview };
|
|
5138
|
+
}
|
|
5139
|
+
if (!preview.importable) {
|
|
5140
|
+
return mockFailure('assistant-package-refused', refusedErrors);
|
|
5141
|
+
}
|
|
5142
|
+
// The import applies the package for real, with the chosen mode, and links its parts.
|
|
5143
|
+
const restoreAll = [this.context.snapshot(), this.snapshot()];
|
|
5144
|
+
const applied = this.apply(agentPackage, options, apply, planned);
|
|
5145
|
+
if (applied.some(part => part.outcome === 'refused')) {
|
|
5146
|
+
for (const restore of restoreAll) {
|
|
5147
|
+
restore();
|
|
5148
|
+
}
|
|
5149
|
+
return mockFailure('assistant-package-refused', refusedErrors);
|
|
5150
|
+
}
|
|
5151
|
+
this.link(agentPackage, options, planned);
|
|
5152
|
+
return {
|
|
5153
|
+
status: 200,
|
|
5154
|
+
body: {
|
|
5155
|
+
packageId: agentPackage.package.id,
|
|
5156
|
+
packageVersion: agentPackage.package.version,
|
|
5157
|
+
hash: mockSha256(canonicalJson(agentPackage)),
|
|
5158
|
+
parts: applied.map(publicPart)
|
|
5159
|
+
}
|
|
5160
|
+
};
|
|
5161
|
+
}
|
|
5162
|
+
/**
|
|
5163
|
+
* Applies every agent and part of the package to the host in dependency order: agents that are no
|
|
5164
|
+
* shadow variant of another agent of the package first, each phase for all of them, then the
|
|
5165
|
+
* variants; reports per part what happened, `refused` with the endpoint's code or field errors.
|
|
5166
|
+
* Parts an administrator unlinked stay as they are (`kept-local`) unless `relink` names them; agents
|
|
5167
|
+
* and parts an earlier version of the package linked that it no longer holds are `left-behind`.
|
|
5168
|
+
*/
|
|
5169
|
+
apply(agentPackage, options, mode, plan) {
|
|
5170
|
+
const packageId = agentPackage.package.id;
|
|
5171
|
+
const ids = new Set(agentPackage.agents.map(agent => agent.id));
|
|
5172
|
+
const live = agentPackage.agents.filter(agent => !agent.definition?.shadowOf || !ids.has(agent.definition.shadowOf));
|
|
5173
|
+
const variants = agentPackage.agents.filter(agent => !live.includes(agent));
|
|
5174
|
+
const results = new Map();
|
|
5175
|
+
const refusedAgents = new Set();
|
|
5176
|
+
for (const group of [live, variants]) {
|
|
5177
|
+
for (const kind of phaseOrder) {
|
|
5178
|
+
for (const agent of group) {
|
|
5179
|
+
if (refusedAgents.has(agent.id)) {
|
|
5180
|
+
continue;
|
|
5181
|
+
}
|
|
5182
|
+
for (const [part, value] of partsOf(agent)) {
|
|
5183
|
+
if (kindOf(part) !== kind) {
|
|
5184
|
+
continue;
|
|
5185
|
+
}
|
|
5186
|
+
const planned = plan?.find(item => item.agentId === agent.id && item.part === part);
|
|
5187
|
+
// The import writes only what the check found new or changed.
|
|
5188
|
+
const outcome = planned && planned.outcome !== 'created' && planned.outcome !== 'changed'
|
|
5189
|
+
? planned
|
|
5190
|
+
: this.applyPart(agentPackage, agent, part, value, options, mode);
|
|
5191
|
+
results.set(agent.id, [...(results.get(agent.id) ?? []), outcome]);
|
|
5192
|
+
if (kind === 'agent' && outcome.outcome === 'refused') {
|
|
5193
|
+
refusedAgents.add(agent.id);
|
|
5194
|
+
}
|
|
5195
|
+
}
|
|
5196
|
+
// Groups go with the agent: they are no part, and stay free once imported.
|
|
5197
|
+
if (kind === 'agent' && !refusedAgents.has(agent.id) && agent.groups && !agent.definition?.shadowOf) {
|
|
5198
|
+
const groups = this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}/groups`, { groups: agent.groups });
|
|
5199
|
+
if (groups.status >= 300) {
|
|
5200
|
+
const refusal = refusalOf(agent.id, 'agent', groups, agent.locks?.['agent'] ?? []);
|
|
5201
|
+
results.set(agent.id, [refusal, ...(results.get(agent.id) ?? []).filter(item => item.part !== 'agent')]);
|
|
5202
|
+
refusedAgents.add(agent.id);
|
|
5203
|
+
}
|
|
5204
|
+
}
|
|
5205
|
+
}
|
|
5206
|
+
}
|
|
5207
|
+
}
|
|
5208
|
+
// What an earlier version of the package linked and this one no longer holds stays, unlinked.
|
|
5209
|
+
const ordered = agentPackage.agents.flatMap(agent => results.get(agent.id) ?? []);
|
|
5210
|
+
for (const [agentId, link] of this.links) {
|
|
5211
|
+
if (link.packageId !== packageId) {
|
|
5212
|
+
continue;
|
|
5213
|
+
}
|
|
5214
|
+
if (!ids.has(agentId)) {
|
|
5215
|
+
ordered.push(leftBehind(agentId, 'agent'));
|
|
5216
|
+
continue;
|
|
5217
|
+
}
|
|
5218
|
+
const held = new Set(partsOf(agentPackage.agents.find(agent => agent.id === agentId)).keys());
|
|
5219
|
+
for (const part of link.parts.keys()) {
|
|
5220
|
+
if (!held.has(part)) {
|
|
5221
|
+
ordered.push(leftBehind(agentId, part));
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
}
|
|
5225
|
+
return ordered;
|
|
5226
|
+
}
|
|
5227
|
+
/** Applies one part through its endpoint and reports what happened to it. */
|
|
5228
|
+
applyPart(agentPackage, agent, part, value, options, mode) {
|
|
5229
|
+
const lockedFields = agent.locks?.[part] ?? [];
|
|
5230
|
+
const link = this.links.get(agent.id);
|
|
5231
|
+
const linkedHere = link?.packageId === agentPackage.package.id;
|
|
5232
|
+
const relinked = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined || item.part === part));
|
|
5233
|
+
const lockProblemCode = lockProblem(agent, part, lockedFields);
|
|
5234
|
+
if (lockProblemCode) {
|
|
5235
|
+
return { ...base(agent.id, part, lockedFields), outcome: 'refused', errors: { locks: [lockProblemCode] } };
|
|
5236
|
+
}
|
|
5237
|
+
const host = this.read(agent.id);
|
|
5238
|
+
if (part === 'agent') {
|
|
5239
|
+
// A relink of the whole agent lets the import take over an agent the host has with its id.
|
|
5240
|
+
const takenOver = (options.relink ?? []).some(item => item.agentId === agent.id && (item.part === null || item.part === undefined));
|
|
5241
|
+
const otherPackage = !!link && !linkedHere && link.parts.size > 0;
|
|
5242
|
+
const refusal = agentRefusal(agent, host?.agent ?? null, linkedHere || takenOver, otherPackage);
|
|
5243
|
+
if (refusal) {
|
|
5244
|
+
return { ...base(agent.id, part, lockedFields), outcome: 'refused', code: refusal };
|
|
5245
|
+
}
|
|
5246
|
+
}
|
|
5247
|
+
if (linkedHere && !relinked && (link.local.has(part) || link.local.has(NH_ASSISTANT_PACKAGE_LOCK_ALL))) {
|
|
5248
|
+
return { ...base(agent.id, part, lockedFields), outcome: 'kept-local' };
|
|
5249
|
+
}
|
|
5250
|
+
const before = host ? this.hostHash(host, part) : null;
|
|
5251
|
+
const partLink = linkedHere ? link.parts.get(part) : undefined;
|
|
5252
|
+
const result = this.write(agent, part, value, host, options, mode);
|
|
5253
|
+
if (result.status >= 300) {
|
|
5254
|
+
return refusalOf(agent.id, part, result, lockedFields);
|
|
5255
|
+
}
|
|
5256
|
+
const after = this.read(agent.id);
|
|
5257
|
+
const hashAfter = after ? this.hostHash(after, part) : null;
|
|
5258
|
+
// In shadow a changed skill or trigger is saved as the candidate next to its live version.
|
|
5259
|
+
const kind = kindOf(part);
|
|
5260
|
+
const candidateVersion = mode === 'shadow' && before !== null && (kind === 'skill' || kind === 'trigger')
|
|
5261
|
+
? result.body?.version ?? null
|
|
5262
|
+
: null;
|
|
5263
|
+
let outcome = 'changed';
|
|
5264
|
+
if (before === null) {
|
|
5265
|
+
outcome = 'created';
|
|
5266
|
+
}
|
|
5267
|
+
else if (candidateVersion === null && before === hashAfter) {
|
|
5268
|
+
outcome = 'unchanged';
|
|
5269
|
+
}
|
|
5270
|
+
return {
|
|
5271
|
+
...base(agent.id, part, lockedFields),
|
|
5272
|
+
outcome,
|
|
5273
|
+
changedLocally: !!partLink && before !== partLink.hash,
|
|
5274
|
+
candidateVersion,
|
|
5275
|
+
hashAfter,
|
|
5276
|
+
viewAfter: this.lockView(agent.id, part)
|
|
5277
|
+
};
|
|
5278
|
+
}
|
|
5279
|
+
/** Writes one part through the endpoint a save of it uses. */
|
|
5280
|
+
write(agent, part, value, host, options, mode) {
|
|
5281
|
+
const kind = kindOf(part);
|
|
5282
|
+
const key = keyOf(part);
|
|
5283
|
+
const agentPath = `admin/agents/${encodeURIComponent(agent.id)}`;
|
|
5284
|
+
const item = value;
|
|
5285
|
+
switch (kind) {
|
|
5286
|
+
case 'agent':
|
|
5287
|
+
return this.writeDefinition(agent, host);
|
|
5288
|
+
case 'runSettings': {
|
|
5289
|
+
const settings = item;
|
|
5290
|
+
const account = settings.accountability === 'service-account' ? options.serviceAccounts?.[agent.id] ?? null : null;
|
|
5291
|
+
return this.context.call('PUT', `${agentPath}/run-settings`, {
|
|
5292
|
+
...settings,
|
|
5293
|
+
serviceAccountActorId: account,
|
|
5294
|
+
expectedVersion: host?.configuration?.runSettings?.version ?? null
|
|
5295
|
+
});
|
|
5296
|
+
}
|
|
5297
|
+
case 'skill': {
|
|
5298
|
+
const { id: _, startsOff, ...skill } = item;
|
|
5299
|
+
const current = host?.configuration?.dataSkills.find(existing => existing.skillId === key);
|
|
5300
|
+
return this.context.call('PUT', `${agentPath}/skills/${encodeURIComponent(key)}`, {
|
|
5301
|
+
...skill,
|
|
5302
|
+
tools: skill['tools'] ?? [],
|
|
5303
|
+
expectedVersion: current?.version ?? null,
|
|
5304
|
+
...(current ? { apply: mode } : startsOff === true ? { startsOff: true } : {})
|
|
5305
|
+
});
|
|
5306
|
+
}
|
|
5307
|
+
case 'trigger': {
|
|
5308
|
+
const { id: _, ...trigger } = item;
|
|
5309
|
+
const current = host?.configuration?.dataTriggers.find(existing => existing.triggerId === key);
|
|
5310
|
+
return this.context.call('PUT', `${agentPath}/triggers/${encodeURIComponent(key)}`, {
|
|
5311
|
+
...trigger,
|
|
5312
|
+
expectedVersion: current?.version ?? null,
|
|
5313
|
+
...(current ? { apply: mode } : {})
|
|
5314
|
+
});
|
|
5315
|
+
}
|
|
5316
|
+
case 'consultation': {
|
|
5317
|
+
const { subjectType: _, startsOff, ...consultation } = item;
|
|
5318
|
+
const current = host?.configuration?.dataConsultations?.find(existing => existing.subjectType === key);
|
|
5319
|
+
return this.context.call('PUT', `${agentPath}/consultations/${encodeURIComponent(key)}`, {
|
|
5320
|
+
...consultation,
|
|
5321
|
+
routes: consultation['routes'] ?? [],
|
|
5322
|
+
expectedVersion: current?.version ?? null,
|
|
5323
|
+
...(!current && startsOff === true ? { startsOff: true } : {})
|
|
5324
|
+
});
|
|
5325
|
+
}
|
|
5326
|
+
case 'handOff': {
|
|
5327
|
+
const { subjectType: _, ...handOff } = item;
|
|
5328
|
+
const current = host?.handOffs?.dataHandOffs.find(existing => existing.subjectType === key);
|
|
5329
|
+
return this.context.call('PUT', `${agentPath}/hand-offs/${encodeURIComponent(key)}`, {
|
|
5330
|
+
...handOff,
|
|
5331
|
+
expectedVersion: current?.version ?? null
|
|
5332
|
+
});
|
|
5333
|
+
}
|
|
5334
|
+
case 'metadata': {
|
|
5335
|
+
const entry = this.read(agent.id)?.metadata.find(existing => existing.name === key);
|
|
5336
|
+
return entry
|
|
5337
|
+
? this.context.call('PUT', `${agentPath}/metadata/${encodeURIComponent(key)}`, { value, expectedVersion: entry.version })
|
|
5338
|
+
: mockFailure('assistant-metadata-entry-not-found');
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
}
|
|
5342
|
+
/**
|
|
5343
|
+
* Writes the definition: creates an agent created here when its id is free, or makes its next
|
|
5344
|
+
* version; overrides an agent in code with the overlay, or restores its code without one.
|
|
5345
|
+
*/
|
|
5346
|
+
writeDefinition(agent, host) {
|
|
5347
|
+
const definition = agent.definition ?? {};
|
|
5348
|
+
if (agent.source === 'code') {
|
|
5349
|
+
const code = this.context.codeDefinition(agent.id);
|
|
5350
|
+
const overlay = codeAgentFields.filter(field => definition[field] !== undefined && definition[field] !== null);
|
|
5351
|
+
if (overlay.length === 0) {
|
|
5352
|
+
return host?.agent.isOverridden
|
|
5353
|
+
? this.context.call('POST', `admin/agents/${encodeURIComponent(agent.id)}/reset`)
|
|
5354
|
+
: { status: 200, body: host?.agent };
|
|
5355
|
+
}
|
|
5356
|
+
return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, {
|
|
5357
|
+
...code,
|
|
5358
|
+
...Object.fromEntries(overlay.map(field => [field, definition[field]])),
|
|
5359
|
+
id: agent.id,
|
|
5360
|
+
mcpServerIds: host?.agent.mcpServerIds ?? [],
|
|
5361
|
+
expectedVersion: host?.agent.version ?? 1
|
|
5362
|
+
});
|
|
5363
|
+
}
|
|
5364
|
+
const worksOn = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
|
|
5365
|
+
const input = {
|
|
5366
|
+
id: agent.id,
|
|
5367
|
+
displayName: definition.displayName ?? '',
|
|
5368
|
+
description: definition.description ?? '',
|
|
5369
|
+
instructions: definition.instructions ?? '',
|
|
5370
|
+
...(definition.instructionsAsset ? { instructionsAsset: definition.instructionsAsset } : {}),
|
|
5371
|
+
toolSelectors: definition.toolSelectors ?? [],
|
|
5372
|
+
mcpServerIds: host?.agent.mcpServerIds ?? [],
|
|
5373
|
+
requiredPolicy: definition.requiredPolicy ?? null,
|
|
5374
|
+
autonomy: definition.autonomy ?? 'explain',
|
|
5375
|
+
...(definition.offeredInChat !== undefined && definition.offeredInChat !== null ? { offeredInChat: definition.offeredInChat } : {}),
|
|
5376
|
+
...(worksOn.some(field => definition[field] !== undefined && definition[field] !== null)
|
|
5377
|
+
? Object.fromEntries(worksOn.map(field => [field, definition[field] ?? null]))
|
|
5378
|
+
: {}),
|
|
5379
|
+
...(definition.profile ? { profile: definition.profile } : {}),
|
|
5380
|
+
...(definition.dataClassification ? { dataClassification: definition.dataClassification } : {})
|
|
5381
|
+
};
|
|
5382
|
+
if (!host) {
|
|
5383
|
+
return this.context.call('POST', 'admin/agents', {
|
|
5384
|
+
...input,
|
|
5385
|
+
...(definition.startsOff ? { startsOff: true } : {}),
|
|
5386
|
+
...(definition.startsInShadow ? { startsInShadow: true } : {}),
|
|
5387
|
+
...(definition.shadowOf ? { shadowOf: definition.shadowOf } : {})
|
|
5388
|
+
});
|
|
5389
|
+
}
|
|
5390
|
+
return this.context.call('PUT', `admin/agents/${encodeURIComponent(agent.id)}`, { ...input, expectedVersion: host.agent.version });
|
|
5391
|
+
}
|
|
5392
|
+
/**
|
|
5393
|
+
* Links the parts of an import: each part the import applied or found unchanged, with its locks and
|
|
5394
|
+
* the hash of the host's part once the package's version is in effect; parts that stayed local stay
|
|
5395
|
+
* so, relinked ones are linked again, and what the package left behind is unlinked.
|
|
5396
|
+
*/
|
|
5397
|
+
link(agentPackage, options, planned) {
|
|
5398
|
+
const packageId = agentPackage.package.id;
|
|
5399
|
+
const version = agentPackage.package.version;
|
|
5400
|
+
const now = this.context.now();
|
|
5401
|
+
for (const agent of agentPackage.agents) {
|
|
5402
|
+
const previous = this.links.get(agent.id);
|
|
5403
|
+
const kept = previous?.packageId === packageId ? previous : undefined;
|
|
5404
|
+
const relinked = (options.relink ?? []).filter(item => item.agentId === agent.id);
|
|
5405
|
+
const local = new Set([...(kept?.local ?? [])].filter(part => !relinked.some(item => item.part === null || item.part === undefined || item.part === part)));
|
|
5406
|
+
if (relinked.some(item => item.part === null || item.part === undefined)) {
|
|
5407
|
+
local.clear();
|
|
5408
|
+
}
|
|
5409
|
+
const parts = new Map();
|
|
5410
|
+
for (const item of planned.filter(entry => entry.agentId === agent.id)) {
|
|
5411
|
+
if (item.outcome === 'kept-local' || item.outcome === 'left-behind' || item.outcome === 'refused') {
|
|
5412
|
+
continue;
|
|
5413
|
+
}
|
|
5414
|
+
const previousLink = kept?.parts.get(item.part);
|
|
5415
|
+
parts.set(item.part, {
|
|
5416
|
+
lockedFields: [...item.lockedFields],
|
|
5417
|
+
hash: item.hashAfter ?? previousLink?.hash ?? '',
|
|
5418
|
+
view: item.viewAfter ?? previousLink?.view ?? null,
|
|
5419
|
+
packageVersion: version,
|
|
5420
|
+
linkedAt: item.outcome === 'unchanged' && previousLink ? previousLink.linkedAt : now,
|
|
5421
|
+
linkedBy: item.outcome === 'unchanged' && previousLink ? previousLink.linkedBy : 'current-user'
|
|
5422
|
+
});
|
|
5423
|
+
}
|
|
5424
|
+
this.links.set(agent.id, { packageId, packageVersion: version, parts, local });
|
|
5425
|
+
}
|
|
5426
|
+
for (const item of planned.filter(entry => entry.outcome === 'left-behind' && entry.part === 'agent')) {
|
|
5427
|
+
this.links.delete(item.agentId);
|
|
5428
|
+
}
|
|
5429
|
+
this.installed.set(packageId, { version, agents: agentPackage.agents.map(agent => agent.id) });
|
|
5430
|
+
}
|
|
5431
|
+
/** `POST admin/agents/{id}/package/unlink`: one part, or without one the whole agent, becomes local. */
|
|
5432
|
+
unlink(agentId, body) {
|
|
5433
|
+
if (!this.read(agentId)) {
|
|
5434
|
+
return mockFailure('assistant-agent-not-found');
|
|
5435
|
+
}
|
|
5436
|
+
const request = (body ?? {});
|
|
5437
|
+
const part = request.part === undefined || request.part === null ? null : request.part;
|
|
5438
|
+
if (part !== null && (typeof part !== 'string' || !validPartKey(part))) {
|
|
5439
|
+
return mockFailure('assistant-validation', { part: ['invalid'] });
|
|
5440
|
+
}
|
|
5441
|
+
if (request.reason !== undefined && request.reason !== null && (typeof request.reason !== 'string' || request.reason.length > mockMaxStateReason)) {
|
|
5442
|
+
return mockFailure('assistant-validation', { reason: ['invalid'] });
|
|
5443
|
+
}
|
|
5444
|
+
const link = this.links.get(agentId);
|
|
5445
|
+
if (!link || link.parts.size === 0 || (part !== null && !link.parts.has(part))) {
|
|
5446
|
+
return mockFailure('assistant-package-not-linked');
|
|
5447
|
+
}
|
|
5448
|
+
const unlinked = part === null ? [...link.parts.keys()] : [part];
|
|
5449
|
+
for (const key of unlinked) {
|
|
5450
|
+
link.parts.delete(key);
|
|
5451
|
+
link.local.add(key);
|
|
5452
|
+
}
|
|
5453
|
+
if (part === null) {
|
|
5454
|
+
link.local.add(NH_ASSISTANT_PACKAGE_LOCK_ALL);
|
|
5455
|
+
}
|
|
5456
|
+
return { status: 200, body: { agentId, packageId: link.packageId, parts: unlinked.sort() } };
|
|
5457
|
+
}
|
|
5458
|
+
/** Whether the agent still has a linked part, so it cannot be deleted or reset. */
|
|
5459
|
+
isLinked(agentId) {
|
|
5460
|
+
return (this.links.get(agentId)?.parts.size ?? 0) > 0;
|
|
5461
|
+
}
|
|
5462
|
+
/** The agent with the package it belongs to, while a part of it is linked. */
|
|
5463
|
+
withAgentPackage(agent) {
|
|
5464
|
+
const link = this.links.get(agent?.id);
|
|
5465
|
+
if (!link || link.parts.size === 0) {
|
|
5466
|
+
return agent;
|
|
5467
|
+
}
|
|
5468
|
+
const read = this.read(agent.id);
|
|
5469
|
+
const value = {
|
|
5470
|
+
packageId: link.packageId,
|
|
5471
|
+
packageVersion: link.packageVersion,
|
|
5472
|
+
parts: [...link.parts.keys()].sort().map(part => this.partLinkOf(agent.id, part, link, read))
|
|
5473
|
+
};
|
|
5474
|
+
return { ...agent, package: value };
|
|
5475
|
+
}
|
|
5476
|
+
/** The item with the link of its part, or as it is when the part is not linked. */
|
|
5477
|
+
withLink(agentId, part, item) {
|
|
5478
|
+
const link = this.links.get(agentId);
|
|
5479
|
+
const value = link ? this.partLinkOf(agentId, part, link, null) : null;
|
|
5480
|
+
return value ? { ...item, package: value } : item;
|
|
5481
|
+
}
|
|
5482
|
+
partLinkOf(agentId, part, link, read) {
|
|
5483
|
+
const partLink = link.parts.get(part);
|
|
5484
|
+
if (!partLink) {
|
|
5485
|
+
return null;
|
|
5486
|
+
}
|
|
5487
|
+
const host = read ?? this.read(agentId);
|
|
5488
|
+
return {
|
|
5489
|
+
part,
|
|
5490
|
+
packageId: link.packageId,
|
|
5491
|
+
packageVersion: partLink.packageVersion,
|
|
5492
|
+
lockedFields: [...partLink.lockedFields],
|
|
5493
|
+
hash: partLink.hash,
|
|
5494
|
+
changedLocally: host ? this.hostHash(host, part) !== partLink.hash : false,
|
|
5495
|
+
linkedAt: partLink.linkedAt,
|
|
5496
|
+
linkedBy: partLink.linkedBy
|
|
5497
|
+
};
|
|
5498
|
+
}
|
|
5499
|
+
/** What the mock has of one agent, read through its own endpoints; `null` for an unknown agent. */
|
|
5500
|
+
read(agentId) {
|
|
5501
|
+
const agent = this.agents().find(item => item.id === agentId);
|
|
5502
|
+
if (!agent) {
|
|
5503
|
+
return null;
|
|
5504
|
+
}
|
|
5505
|
+
const path = `admin/agents/${encodeURIComponent(agentId)}`;
|
|
5506
|
+
const configuration = this.context.call('GET', `${path}/configuration`);
|
|
5507
|
+
const handOffs = this.context.call('GET', `${path}/hand-offs`);
|
|
5508
|
+
const metadata = this.context.call('GET', `${path}/metadata`);
|
|
5509
|
+
return {
|
|
5510
|
+
agent,
|
|
5511
|
+
configuration: configuration.status === 200 ? configuration.body : null,
|
|
5512
|
+
handOffs: handOffs.status === 200 ? handOffs.body : null,
|
|
5513
|
+
metadata: metadata.status === 200 ? metadata.body : []
|
|
5514
|
+
};
|
|
5515
|
+
}
|
|
5516
|
+
agents() {
|
|
5517
|
+
const result = this.context.call('GET', 'admin/agents');
|
|
5518
|
+
return result.status === 200 ? result.body : [];
|
|
5519
|
+
}
|
|
5520
|
+
/** The SHA-256 of the canonical JSON of the host's part as a package holds it; `null` without the part. */
|
|
5521
|
+
hostHash(read, part) {
|
|
5522
|
+
const value = partsOf(this.packageAgentOf(read)).get(part);
|
|
5523
|
+
return value === undefined ? null : mockSha256(canonicalJson(value));
|
|
5524
|
+
}
|
|
5525
|
+
/**
|
|
5526
|
+
* What a save may not change of a part: the agent's definition as it is in effect (for an agent in
|
|
5527
|
+
* code with its code's values), or the part as a package holds it.
|
|
5528
|
+
*/
|
|
5529
|
+
lockView(agentId, part) {
|
|
5530
|
+
const read = this.read(agentId);
|
|
5531
|
+
if (!read) {
|
|
5532
|
+
return null;
|
|
5533
|
+
}
|
|
5534
|
+
if (part === 'agent') {
|
|
5535
|
+
return agentDefinitionOf(read.agent);
|
|
5536
|
+
}
|
|
5537
|
+
const value = partsOf(this.packageAgentOf(read)).get(part);
|
|
5538
|
+
return value === undefined ? null : part.startsWith('metadata:') ? { value } : value;
|
|
5539
|
+
}
|
|
5540
|
+
/** The agent as a package holds it, without locks and hashes. */
|
|
5541
|
+
packageAgentOf(read) {
|
|
5542
|
+
const { agent, configuration, handOffs, metadata } = read;
|
|
5543
|
+
const inCode = agent.source === 'code';
|
|
5544
|
+
const definition = inCode ? this.overlayOf(agent) : agentDefinitionOf(agent);
|
|
5545
|
+
const values = metadata.filter(entry => entry.source === (inCode ? 'override' : 'data'));
|
|
5546
|
+
// The projections are the format's JSON; the format's types name the same properties.
|
|
5547
|
+
const packageAgent = {
|
|
5548
|
+
id: agent.id,
|
|
5549
|
+
source: agent.source,
|
|
5550
|
+
...(definition && Object.keys(definition).length > 0 ? { definition } : {}),
|
|
5551
|
+
...(!inCode && configuration?.runSettings ? { runSettings: runSettingsOf(configuration.runSettings) } : {}),
|
|
5552
|
+
...nonEmpty('skills', (configuration?.dataSkills ?? []).map(skillOf)),
|
|
5553
|
+
...nonEmpty('triggers', (configuration?.dataTriggers ?? []).map(triggerOf)),
|
|
5554
|
+
...nonEmpty('consultations', (configuration?.dataConsultations ?? []).map(consultationOf)),
|
|
5555
|
+
...nonEmpty('handOffs', (handOffs?.dataHandOffs ?? []).map(handOff => ({
|
|
5556
|
+
subjectType: handOff.subjectType,
|
|
5557
|
+
sources: [...handOff.sources],
|
|
5558
|
+
description: handOff.description,
|
|
5559
|
+
notifySource: handOff.notifySource,
|
|
5560
|
+
maxPerSubjectPerDay: handOff.maxPerSubjectPerDay
|
|
5561
|
+
})))
|
|
5562
|
+
};
|
|
5563
|
+
if (values.length > 0) {
|
|
5564
|
+
packageAgent['metadata'] = Object.fromEntries(values.map(entry => [entry.name, entry.value]));
|
|
5565
|
+
}
|
|
5566
|
+
if (!agent.shadowOf && (agent.groups?.length ?? 0) > 0) {
|
|
5567
|
+
packageAgent['groups'] = [...agent.groups];
|
|
5568
|
+
}
|
|
5569
|
+
return packageAgent;
|
|
5570
|
+
}
|
|
5571
|
+
/** The overrides of an agent in code: the fields of its definition that differ from its code. */
|
|
5572
|
+
overlayOf(agent) {
|
|
5573
|
+
const code = this.context.codeDefinition(agent.id);
|
|
5574
|
+
if (!code || !agent.isOverridden) {
|
|
5575
|
+
return {};
|
|
5576
|
+
}
|
|
5577
|
+
const definition = agent;
|
|
5578
|
+
const inCode = code;
|
|
5579
|
+
return Object.fromEntries(codeAgentFields
|
|
5580
|
+
.filter(field => !sameValue(definition[field], inCode[field]))
|
|
5581
|
+
.map(field => [field, definition[field]]));
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5584
|
+
/** The part of the administration a request saves or removes, or the whole agent it deletes or resets. */
|
|
5585
|
+
function guardedPart(method, segments) {
|
|
5586
|
+
if (segments[0] !== 'admin' || segments[1] !== 'agents' || segments[2] === undefined) {
|
|
5587
|
+
return null;
|
|
5588
|
+
}
|
|
5589
|
+
const [, , agentId, area, id, action, child] = segments;
|
|
5590
|
+
if (segments.length === 3) {
|
|
5591
|
+
return method === 'PUT' ? { agentId, part: 'agent', removal: false } : method === 'DELETE' ? { agentId, part: null, removal: true } : null;
|
|
5592
|
+
}
|
|
5593
|
+
// A reset restores the code's definition, which the locks keep as the package has it.
|
|
5594
|
+
if (area === 'reset' && segments.length === 4) {
|
|
5595
|
+
return method === 'POST' ? { agentId, part: 'agent', removal: false, reset: true } : null;
|
|
5596
|
+
}
|
|
5597
|
+
if (area === 'run-settings' && segments.length === 4) {
|
|
5598
|
+
return method === 'PUT' || method === 'DELETE' ? { agentId, part: 'runSettings', removal: method === 'DELETE' } : null;
|
|
5599
|
+
}
|
|
5600
|
+
const kinds = {
|
|
5601
|
+
skills: 'skill',
|
|
5602
|
+
triggers: 'trigger',
|
|
5603
|
+
consultations: 'consultation',
|
|
5604
|
+
'hand-offs': 'handOff',
|
|
5605
|
+
metadata: 'metadata'
|
|
5606
|
+
};
|
|
5607
|
+
const kind = area === undefined ? undefined : kinds[area];
|
|
5608
|
+
if (kind === undefined || id === undefined) {
|
|
5609
|
+
return null;
|
|
5610
|
+
}
|
|
5611
|
+
const part = `${kind}:${id}`;
|
|
5612
|
+
if (segments.length === 5) {
|
|
5613
|
+
return method === 'PUT' || method === 'DELETE' ? { agentId, part, removal: method === 'DELETE' } : null;
|
|
5614
|
+
}
|
|
5615
|
+
// Promoting a candidate makes it the live version, which the locks keep as the package has it.
|
|
5616
|
+
if ((kind === 'skill' || kind === 'trigger') && action === 'candidate' && child === 'promote' && segments.length === 7 && method === 'POST') {
|
|
5617
|
+
return { agentId, part, removal: false };
|
|
5618
|
+
}
|
|
5619
|
+
return null;
|
|
5620
|
+
}
|
|
5621
|
+
/**
|
|
5622
|
+
* The locked fields a save changed to something else than the package's value: promoting the
|
|
5623
|
+
* candidate an import made of the package's version is no change of a locked field.
|
|
5624
|
+
*/
|
|
5625
|
+
function lockedChanges(kind, locked, before, after, packaged) {
|
|
5626
|
+
const fields = locked.includes(NH_ASSISTANT_PACKAGE_LOCK_ALL) ? NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind] : locked;
|
|
5627
|
+
return fields.filter(field => {
|
|
5628
|
+
const properties = lockProperties[kind][field] ?? [field];
|
|
5629
|
+
const changed = properties.some(property => !sameValue(before?.[property], after?.[property]));
|
|
5630
|
+
const asPackaged = packaged !== null && properties.every(property => sameValue(after?.[property], packaged[property]));
|
|
5631
|
+
return changed && !asPackaged;
|
|
5632
|
+
});
|
|
5633
|
+
}
|
|
5634
|
+
/** Why a lock of a package agent is invalid: `not-found` for a part it does not hold, `invalid` fields. */
|
|
5635
|
+
function lockProblem(agent, part, fields) {
|
|
5636
|
+
if (fields.length === 0) {
|
|
5637
|
+
return null;
|
|
5638
|
+
}
|
|
5639
|
+
if (!partsOf(agent).has(part)) {
|
|
5640
|
+
return 'not-found';
|
|
5641
|
+
}
|
|
5642
|
+
const kind = kindOf(part);
|
|
5643
|
+
const allowed = kind === 'agent' && agent.source === 'code'
|
|
5644
|
+
? NH_ASSISTANT_PACKAGE_LOCK_FIELDS.agent.slice(0, codeAgentFields.length)
|
|
5645
|
+
: NH_ASSISTANT_PACKAGE_LOCK_FIELDS[kind];
|
|
5646
|
+
const whole = fields.length === 1 && fields[0] === NH_ASSISTANT_PACKAGE_LOCK_ALL;
|
|
5647
|
+
return whole || fields.every(field => allowed.includes(field)) ? null : 'invalid';
|
|
5648
|
+
}
|
|
5649
|
+
/**
|
|
5650
|
+
* Why the agent of a package may not be imported onto the host's agent with its id, or `null`: an
|
|
5651
|
+
* agent of another package, an agent in code for an agent created here, or an agent created here that
|
|
5652
|
+
* the package neither linked nor takes over (`relink` naming the whole agent).
|
|
5653
|
+
*/
|
|
5654
|
+
function agentRefusal(agent, host, linkedToPackage, otherPackage) {
|
|
5655
|
+
if (otherPackage) {
|
|
5656
|
+
return 'assistant-agent-exists';
|
|
5657
|
+
}
|
|
5658
|
+
if (agent.source === 'code') {
|
|
5659
|
+
if (!host) {
|
|
5660
|
+
return 'assistant-agent-not-found';
|
|
5661
|
+
}
|
|
5662
|
+
return host.source === 'code' ? null : 'assistant-agent-not-code';
|
|
5663
|
+
}
|
|
5664
|
+
if (host && (host.source === 'code' || !linkedToPackage)) {
|
|
5665
|
+
return 'assistant-agent-exists';
|
|
5666
|
+
}
|
|
5667
|
+
return null;
|
|
5668
|
+
}
|
|
5669
|
+
/** The parts of a package agent by part key, in the format's order. */
|
|
5670
|
+
function partsOf(agent) {
|
|
5671
|
+
const parts = new Map();
|
|
5672
|
+
if (agent.definition) {
|
|
5673
|
+
parts.set('agent', agent.definition);
|
|
5674
|
+
}
|
|
5675
|
+
if (agent.runSettings) {
|
|
5676
|
+
parts.set('runSettings', agent.runSettings);
|
|
5677
|
+
}
|
|
5678
|
+
for (const skill of agent.skills ?? []) {
|
|
5679
|
+
parts.set(`skill:${skill.id}`, skill);
|
|
5680
|
+
}
|
|
5681
|
+
for (const trigger of agent.triggers ?? []) {
|
|
5682
|
+
parts.set(`trigger:${trigger.id}`, trigger);
|
|
5683
|
+
}
|
|
5684
|
+
for (const consultation of agent.consultations ?? []) {
|
|
5685
|
+
parts.set(`consultation:${consultation.subjectType}`, consultation);
|
|
5686
|
+
}
|
|
5687
|
+
for (const handOff of agent.handOffs ?? []) {
|
|
5688
|
+
parts.set(`handOff:${handOff.subjectType}`, handOff);
|
|
5689
|
+
}
|
|
5690
|
+
for (const [name, value] of Object.entries(agent.metadata ?? {})) {
|
|
5691
|
+
parts.set(`metadata:${name}`, value);
|
|
5692
|
+
}
|
|
5693
|
+
return parts;
|
|
5694
|
+
}
|
|
5695
|
+
function kindOf(part) {
|
|
5696
|
+
const colon = part.indexOf(':');
|
|
5697
|
+
return (colon < 0 ? part : part.slice(0, colon));
|
|
5698
|
+
}
|
|
5699
|
+
function keyOf(part) {
|
|
5700
|
+
const colon = part.indexOf(':');
|
|
5701
|
+
return colon < 0 ? '' : part.slice(colon + 1);
|
|
5702
|
+
}
|
|
5703
|
+
/** Whether a part key names a known kind, with a dash-case key for the kinds that take one. */
|
|
5704
|
+
function validPartKey(part) {
|
|
5705
|
+
if (part === 'agent' || part === 'runSettings') {
|
|
5706
|
+
return true;
|
|
5707
|
+
}
|
|
5708
|
+
const kind = kindOf(part);
|
|
5709
|
+
return ['skill', 'trigger', 'consultation', 'handOff', 'metadata'].includes(kind) && mockDashCase.test(keyOf(part));
|
|
5710
|
+
}
|
|
5711
|
+
/** The definition of an agent as a package holds an agent created here. */
|
|
5712
|
+
function agentDefinitionOf(agent) {
|
|
5713
|
+
return {
|
|
5714
|
+
displayName: agent.displayName,
|
|
5715
|
+
description: agent.description,
|
|
5716
|
+
instructions: agent.instructionsAsset ? null : agent.instructions,
|
|
5717
|
+
instructionsAsset: agent.instructionsAsset ?? null,
|
|
5718
|
+
toolSelectors: [...agent.toolSelectors],
|
|
5719
|
+
requiredPolicy: agent.requiredPolicy,
|
|
5720
|
+
autonomy: agent.autonomy,
|
|
5721
|
+
offeredInChat: agent.offeredInChat ?? true,
|
|
5722
|
+
worksOnSubjectTypes: agent.worksOnSubjectTypes ?? null,
|
|
5723
|
+
worksOnAllSubjects: agent.worksOnAllSubjects ?? null,
|
|
5724
|
+
worksOnApplication: agent.worksOnApplication ?? null,
|
|
5725
|
+
perDivision: agent.perDivision ?? null,
|
|
5726
|
+
runPolicy: agent.runPolicy ?? null,
|
|
5727
|
+
viewPolicy: agent.viewPolicy ?? null,
|
|
5728
|
+
profile: agent.profile ?? null,
|
|
5729
|
+
dataClassification: agent.dataClassification ?? null,
|
|
5730
|
+
shadowOf: agent.shadowOf ?? null
|
|
5731
|
+
};
|
|
5732
|
+
}
|
|
5733
|
+
function runSettingsOf(settings) {
|
|
5734
|
+
return {
|
|
5735
|
+
accountability: settings.accountability,
|
|
5736
|
+
delegateFromInitiator: settings.delegateFromInitiator,
|
|
5737
|
+
allowGatewayReads: settings.allowGatewayReads,
|
|
5738
|
+
elevatedReads: settings.elevatedReads,
|
|
5739
|
+
approvalPolicy: settings.approvalPolicy,
|
|
5740
|
+
retryCount: settings.retryCount,
|
|
5741
|
+
dailyToolCalls: settings.dailyToolCalls,
|
|
5742
|
+
dailyTokens: settings.dailyTokens,
|
|
5743
|
+
maxConcurrentRuns: settings.maxConcurrentRuns,
|
|
5744
|
+
maxRunsPerSubjectPerDay: settings.maxRunsPerSubjectPerDay,
|
|
5745
|
+
maxTokensPerRun: settings.maxTokensPerRun,
|
|
5746
|
+
triageProfile: settings.triageProfile,
|
|
5747
|
+
ruleHandler: settings.ruleHandler,
|
|
5748
|
+
acceptsFiles: settings.acceptsFiles ?? false,
|
|
5749
|
+
maxFileBytes: settings.maxFileBytes ?? null,
|
|
5750
|
+
maxFilePages: settings.maxFilePages ?? null,
|
|
5751
|
+
maxFilesPerTurn: settings.maxFilesPerTurn ?? null,
|
|
5752
|
+
maxFileTokensPerTurn: settings.maxFileTokensPerTurn ?? null
|
|
5753
|
+
};
|
|
5754
|
+
}
|
|
5755
|
+
function skillOf(skill) {
|
|
5756
|
+
return {
|
|
5757
|
+
id: skill.skillId,
|
|
5758
|
+
description: skill.description,
|
|
5759
|
+
instructions: skill.instructionsAsset ? null : skill.instructions,
|
|
5760
|
+
instructionsAsset: skill.instructionsAsset ?? null,
|
|
5761
|
+
tools: [...skill.tools],
|
|
5762
|
+
profile: skill.profile,
|
|
5763
|
+
ruleHandler: skill.ruleHandler,
|
|
5764
|
+
maxTokensPerRun: skill.maxTokensPerRun,
|
|
5765
|
+
dailyToolCalls: skill.dailyToolCalls,
|
|
5766
|
+
dailyTokens: skill.dailyTokens,
|
|
5767
|
+
approvalPolicy: skill.approvalPolicy,
|
|
5768
|
+
dataClassification: skill.dataClassification ?? null,
|
|
5769
|
+
acceptsFiles: skill.acceptsFiles ?? false,
|
|
5770
|
+
maxFileBytes: skill.maxFileBytes ?? null,
|
|
5771
|
+
maxFilePages: skill.maxFilePages ?? null,
|
|
5772
|
+
maxFilesPerTurn: skill.maxFilesPerTurn ?? null,
|
|
5773
|
+
maxFileTokensPerTurn: skill.maxFileTokensPerTurn ?? null
|
|
5774
|
+
};
|
|
5775
|
+
}
|
|
5776
|
+
function triggerOf(trigger) {
|
|
5777
|
+
return {
|
|
5778
|
+
id: trigger.triggerId,
|
|
5779
|
+
source: trigger.source,
|
|
5780
|
+
skill: trigger.skillId,
|
|
5781
|
+
event: trigger.event,
|
|
5782
|
+
filters: [...(trigger.filters ?? [])],
|
|
5783
|
+
cron: trigger.cron,
|
|
5784
|
+
timeZone: trigger.timeZone,
|
|
5785
|
+
subjectSet: trigger.subjectSet,
|
|
5786
|
+
message: trigger.message,
|
|
5787
|
+
conflict: trigger.conflict,
|
|
5788
|
+
debounceSeconds: trigger.debounceSeconds
|
|
5789
|
+
};
|
|
5790
|
+
}
|
|
5791
|
+
function consultationOf(consultation) {
|
|
5792
|
+
return {
|
|
5793
|
+
subjectType: consultation.subjectType,
|
|
5794
|
+
description: consultation.description,
|
|
5795
|
+
instructions: consultation.instructions,
|
|
5796
|
+
minimumRole: consultation.minimumRole,
|
|
5797
|
+
askerPolicy: consultation.askerPolicy,
|
|
5798
|
+
routes: [...consultation.routes],
|
|
5799
|
+
proposalRole: consultation.proposalRole,
|
|
5800
|
+
proposalPolicy: consultation.proposalPolicy,
|
|
5801
|
+
toolSelectors: consultation.toolSelectors ?? null,
|
|
5802
|
+
maxToolCalls: consultation.maxToolCalls,
|
|
5803
|
+
timeoutSeconds: consultation.timeoutSeconds,
|
|
5804
|
+
maxAnswerChars: consultation.maxAnswerChars,
|
|
5805
|
+
maxHistoryMessages: consultation.maxHistoryMessages,
|
|
5806
|
+
allowRequests: consultation.allowRequests,
|
|
5807
|
+
exposeToMcp: consultation.exposeToMcp,
|
|
5808
|
+
dataClassification: consultation.dataClassification
|
|
5809
|
+
};
|
|
5810
|
+
}
|
|
5811
|
+
/** `{ [name]: items }` for a list with items, otherwise nothing, as the format leaves empty lists out. */
|
|
5812
|
+
function nonEmpty(name, items) {
|
|
5813
|
+
return items.length > 0 ? { [name]: items } : {};
|
|
5814
|
+
}
|
|
5815
|
+
/**
|
|
5816
|
+
* Reads a package strictly, as the server does: `assistant-package-too-large` above 1 MB or 20
|
|
5817
|
+
* agents; `assistant-package-format-unsupported` for another format, a newer format version, an
|
|
5818
|
+
* unknown property, a missing required one or an agent id given twice.
|
|
5819
|
+
*/
|
|
5820
|
+
function readPackage(value) {
|
|
5821
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
5822
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5823
|
+
}
|
|
5824
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES) {
|
|
5825
|
+
return mockFailure('assistant-package-too-large');
|
|
5826
|
+
}
|
|
5827
|
+
const root = value;
|
|
5828
|
+
if (root['format'] !== NH_ASSISTANT_AGENT_PACKAGE_FORMAT
|
|
5829
|
+
|| typeof root['formatVersion'] !== 'number'
|
|
5830
|
+
|| !Number.isInteger(root['formatVersion'])
|
|
5831
|
+
|| root['formatVersion'] < 1
|
|
5832
|
+
|| root['formatVersion'] > NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION) {
|
|
5833
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5834
|
+
}
|
|
5835
|
+
const agents = root['agents'];
|
|
5836
|
+
if (!fits(root, 'root') || !fits(root['package'], 'package') || !Array.isArray(agents)) {
|
|
5837
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5838
|
+
}
|
|
5839
|
+
if (agents.length > NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS) {
|
|
5840
|
+
return mockFailure('assistant-package-too-large');
|
|
5841
|
+
}
|
|
5842
|
+
const info = root['package'];
|
|
5843
|
+
const ids = new Set();
|
|
5844
|
+
for (const item of agents) {
|
|
5845
|
+
if (!fitsAgent(item)) {
|
|
5846
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5847
|
+
}
|
|
5848
|
+
const id = item['id'];
|
|
5849
|
+
if (ids.has(id)) {
|
|
5850
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5851
|
+
}
|
|
5852
|
+
ids.add(id);
|
|
5853
|
+
}
|
|
5854
|
+
if (agents.length === 0) {
|
|
5855
|
+
return mockFailure('assistant-package-format-unsupported');
|
|
5856
|
+
}
|
|
5857
|
+
const errors = {};
|
|
5858
|
+
if (typeof info['id'] !== 'string' || !mockDashCase.test(info['id']) || info['id'].length > maxPackageId) {
|
|
5859
|
+
errors['package.id'] = ['invalid'];
|
|
5860
|
+
}
|
|
5861
|
+
if (typeof info['version'] !== 'number' || !Number.isInteger(info['version']) || info['version'] < 1) {
|
|
5862
|
+
errors['package.version'] = ['invalid'];
|
|
5863
|
+
}
|
|
5864
|
+
if (info['description'] !== undefined && info['description'] !== null
|
|
5865
|
+
&& (typeof info['description'] !== 'string' || info['description'].length > maxPackageDescription)) {
|
|
5866
|
+
errors['package.description'] = ['too-long'];
|
|
5867
|
+
}
|
|
5868
|
+
return Object.keys(errors).length > 0 ? mockFailure('assistant-validation', errors) : { package: structuredClone(value) };
|
|
5869
|
+
}
|
|
5870
|
+
function fitsAgent(value) {
|
|
5871
|
+
if (!fits(value, 'agent')) {
|
|
5872
|
+
return false;
|
|
5873
|
+
}
|
|
5874
|
+
const agent = value;
|
|
5875
|
+
if (typeof agent['id'] !== 'string' || (agent['source'] !== 'admin' && agent['source'] !== 'code')) {
|
|
5876
|
+
return false;
|
|
5877
|
+
}
|
|
5878
|
+
if (agent['source'] === 'admin' && (agent['definition'] === undefined || agent['definition'] === null)) {
|
|
5879
|
+
return false;
|
|
5880
|
+
}
|
|
5881
|
+
const definition = agent['definition'];
|
|
5882
|
+
if (definition !== undefined && definition !== null
|
|
5883
|
+
&& (!fits(definition, 'definition') || (definition['instructionsAsset'] && !fits(definition['instructionsAsset'], 'instructionsAsset')))) {
|
|
5884
|
+
return false;
|
|
5885
|
+
}
|
|
5886
|
+
if (agent['runSettings'] !== undefined && agent['runSettings'] !== null && !fits(agent['runSettings'], 'runSettings')) {
|
|
5887
|
+
return false;
|
|
5888
|
+
}
|
|
5889
|
+
const lists = [['skills', 'skill'], ['triggers', 'trigger'], ['consultations', 'consultation'], ['handOffs', 'handOff']];
|
|
5890
|
+
for (const [list, shape] of lists) {
|
|
5891
|
+
const items = agent[list];
|
|
5892
|
+
if (items !== undefined && items !== null && (!Array.isArray(items) || !items.every(item => fits(item, shape)))) {
|
|
5893
|
+
return false;
|
|
5894
|
+
}
|
|
5895
|
+
}
|
|
5896
|
+
for (const map of ['metadata', 'locks', 'hashes']) {
|
|
5897
|
+
const item = agent[map];
|
|
5898
|
+
if (item !== undefined && item !== null && (typeof item !== 'object' || Array.isArray(item))) {
|
|
5899
|
+
return false;
|
|
3733
5900
|
}
|
|
3734
5901
|
}
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
return
|
|
3742
|
-
...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
|
|
3743
|
-
...this.runConfiguration.consultationTools(),
|
|
3744
|
-
...this.mcp.catalogEntries()
|
|
3745
|
-
];
|
|
5902
|
+
const groups = agent['groups'];
|
|
5903
|
+
return groups === undefined || groups === null || (Array.isArray(groups) && groups.every(group => typeof group === 'string'));
|
|
5904
|
+
}
|
|
5905
|
+
/** Whether an object has every required property of its shape and none it does not know. */
|
|
5906
|
+
function fits(value, shape) {
|
|
5907
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
5908
|
+
return false;
|
|
3746
5909
|
}
|
|
5910
|
+
const { required, optional } = formatShapes[shape];
|
|
5911
|
+
const record = value;
|
|
5912
|
+
return required.every(name => record[name] !== undefined && record[name] !== null)
|
|
5913
|
+
&& Object.keys(record).every(name => required.includes(name) || optional.includes(name));
|
|
3747
5914
|
}
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
5915
|
+
function base(agentId, part, lockedFields) {
|
|
5916
|
+
return {
|
|
5917
|
+
agentId,
|
|
5918
|
+
part,
|
|
5919
|
+
outcome: 'unchanged',
|
|
5920
|
+
changedLocally: false,
|
|
5921
|
+
code: null,
|
|
5922
|
+
errors: null,
|
|
5923
|
+
lockedFields: [...lockedFields],
|
|
5924
|
+
candidateVersion: null,
|
|
5925
|
+
hashAfter: null,
|
|
5926
|
+
viewAfter: null
|
|
5927
|
+
};
|
|
5928
|
+
}
|
|
5929
|
+
function leftBehind(agentId, part) {
|
|
5930
|
+
return { ...base(agentId, part, []), outcome: 'left-behind' };
|
|
5931
|
+
}
|
|
5932
|
+
/** A refused part with the endpoint's field errors, or its code without them. */
|
|
5933
|
+
function refusalOf(agentId, part, result, lockedFields) {
|
|
5934
|
+
const body = (result.body ?? {});
|
|
5935
|
+
const errors = body.errors && Object.keys(body.errors).length > 0 ? body.errors : null;
|
|
5936
|
+
return {
|
|
5937
|
+
...base(agentId, part, lockedFields),
|
|
5938
|
+
outcome: 'refused',
|
|
5939
|
+
code: errors ? null : body.code ?? 'assistant-validation',
|
|
5940
|
+
errors
|
|
5941
|
+
};
|
|
5942
|
+
}
|
|
5943
|
+
/**
|
|
5944
|
+
* The field errors of `assistant-package-refused`, as the server answers them: one plain field, since
|
|
5945
|
+
* the preview's part outcomes say why each part was refused.
|
|
5946
|
+
*/
|
|
5947
|
+
const refusedErrors = { parts: ['invalid'] };
|
|
5948
|
+
function publicPart({ hashAfter: _, viewAfter: __, ...part }) {
|
|
5949
|
+
return part;
|
|
5950
|
+
}
|
|
5951
|
+
/** A trimmed text, or `null` for none. */
|
|
5952
|
+
function text(value) {
|
|
5953
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
5954
|
+
}
|
|
5955
|
+
/** Whether two values are the same as a package holds them: `null` and missing alike. */
|
|
5956
|
+
function sameValue(left, right) {
|
|
5957
|
+
return canonicalJson(left ?? null) === canonicalJson(right ?? null);
|
|
5958
|
+
}
|
|
5959
|
+
/** A copy of a value without `null` and `undefined` properties, as the format writes it. */
|
|
5960
|
+
function withoutNulls(value) {
|
|
5961
|
+
if (Array.isArray(value)) {
|
|
5962
|
+
return value.map(item => withoutNulls(item));
|
|
3752
5963
|
}
|
|
5964
|
+
if (value && typeof value === 'object') {
|
|
5965
|
+
return Object.fromEntries(Object.entries(value)
|
|
5966
|
+
.filter(([, item]) => item !== null && item !== undefined)
|
|
5967
|
+
.map(([key, item]) => [key, withoutNulls(item)]));
|
|
5968
|
+
}
|
|
5969
|
+
return value;
|
|
3753
5970
|
}
|
|
3754
|
-
|
|
3755
|
-
|
|
5971
|
+
/**
|
|
5972
|
+
* The canonical JSON of a value, as the format hashes a part: object properties ordered by name
|
|
5973
|
+
* (ordinal), properties with a `null` value left out, no white space.
|
|
5974
|
+
*/
|
|
5975
|
+
function canonicalJson(value) {
|
|
5976
|
+
if (value === null || value === undefined) {
|
|
5977
|
+
return 'null';
|
|
5978
|
+
}
|
|
5979
|
+
if (Array.isArray(value)) {
|
|
5980
|
+
return `[${value.map(item => canonicalJson(item)).join(',')}]`;
|
|
5981
|
+
}
|
|
5982
|
+
if (typeof value === 'object') {
|
|
5983
|
+
const entries = Object.entries(value)
|
|
5984
|
+
.filter(([, item]) => item !== null && item !== undefined)
|
|
5985
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
|
|
5986
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
|
|
5987
|
+
}
|
|
5988
|
+
return JSON.stringify(value);
|
|
5989
|
+
}
|
|
5990
|
+
const sha256Constants = new Uint32Array([
|
|
5991
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
5992
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
5993
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
5994
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
5995
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
5996
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
5997
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
5998
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
5999
|
+
]);
|
|
6000
|
+
/**
|
|
6001
|
+
* The SHA-256 of the UTF-8 bytes of a text, lowercase hex, computed synchronously so an import can
|
|
6002
|
+
* hash its parts between two of its writes.
|
|
6003
|
+
*/
|
|
6004
|
+
function mockSha256(input) {
|
|
6005
|
+
const bytes = new TextEncoder().encode(input);
|
|
6006
|
+
const length = ((bytes.length + 9 + 63) >> 6) << 6;
|
|
6007
|
+
const data = new Uint8Array(length);
|
|
6008
|
+
data.set(bytes);
|
|
6009
|
+
data[bytes.length] = 0x80;
|
|
6010
|
+
const view = new DataView(data.buffer);
|
|
6011
|
+
view.setUint32(length - 8, Math.floor(bytes.length / 0x20000000));
|
|
6012
|
+
view.setUint32(length - 4, (bytes.length << 3) >>> 0);
|
|
6013
|
+
const hash = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
|
|
6014
|
+
const words = new Uint32Array(64);
|
|
6015
|
+
const rotate = (value, bits) => (value >>> bits) | (value << (32 - bits));
|
|
6016
|
+
for (let offset = 0; offset < length; offset += 64) {
|
|
6017
|
+
for (let index = 0; index < 16; index++) {
|
|
6018
|
+
words[index] = view.getUint32(offset + index * 4);
|
|
6019
|
+
}
|
|
6020
|
+
for (let index = 16; index < 64; index++) {
|
|
6021
|
+
const low = words[index - 15];
|
|
6022
|
+
const high = words[index - 2];
|
|
6023
|
+
const s0 = rotate(low, 7) ^ rotate(low, 18) ^ (low >>> 3);
|
|
6024
|
+
const s1 = rotate(high, 17) ^ rotate(high, 19) ^ (high >>> 10);
|
|
6025
|
+
words[index] = (words[index - 16] + s0 + words[index - 7] + s1) >>> 0;
|
|
6026
|
+
}
|
|
6027
|
+
let [a, b, c, d, e, f, g, h] = hash;
|
|
6028
|
+
for (let index = 0; index < 64; index++) {
|
|
6029
|
+
const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
|
|
6030
|
+
const choice = (e & f) ^ (~e & g);
|
|
6031
|
+
const first = (h + s1 + choice + sha256Constants[index] + words[index]) >>> 0;
|
|
6032
|
+
const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
|
|
6033
|
+
const majority = (a & b) ^ (a & c) ^ (b & c);
|
|
6034
|
+
const second = (s0 + majority) >>> 0;
|
|
6035
|
+
h = g;
|
|
6036
|
+
g = f;
|
|
6037
|
+
f = e;
|
|
6038
|
+
e = (d + first) >>> 0;
|
|
6039
|
+
d = c;
|
|
6040
|
+
c = b;
|
|
6041
|
+
b = a;
|
|
6042
|
+
a = (first + second) >>> 0;
|
|
6043
|
+
}
|
|
6044
|
+
hash[0] = (hash[0] + a) >>> 0;
|
|
6045
|
+
hash[1] = (hash[1] + b) >>> 0;
|
|
6046
|
+
hash[2] = (hash[2] + c) >>> 0;
|
|
6047
|
+
hash[3] = (hash[3] + d) >>> 0;
|
|
6048
|
+
hash[4] = (hash[4] + e) >>> 0;
|
|
6049
|
+
hash[5] = (hash[5] + f) >>> 0;
|
|
6050
|
+
hash[6] = (hash[6] + g) >>> 0;
|
|
6051
|
+
hash[7] = (hash[7] + h) >>> 0;
|
|
6052
|
+
}
|
|
6053
|
+
return [...hash].map(word => word.toString(16).padStart(8, '0')).join('');
|
|
3756
6054
|
}
|
|
3757
6055
|
|
|
3758
6056
|
/** The longest file name a reference takes, as on the server. */
|
|
@@ -3777,7 +6075,7 @@ const binarySignatures = [
|
|
|
3777
6075
|
];
|
|
3778
6076
|
/** How files of the platform's types start; other accepted types are the application's to check. */
|
|
3779
6077
|
const signatures = {
|
|
3780
|
-
'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
6078
|
+
'application/pdf': head => indexOf$1(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
3781
6079
|
'image/png': head => startsWith(head, pngSignature),
|
|
3782
6080
|
'image/jpeg': head => startsWith(head, jpegSignature),
|
|
3783
6081
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, zipSignature),
|
|
@@ -3936,7 +6234,7 @@ function refusalCode(code) {
|
|
|
3936
6234
|
function startsWith(head, signature) {
|
|
3937
6235
|
return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
|
|
3938
6236
|
}
|
|
3939
|
-
function indexOf(head, signature) {
|
|
6237
|
+
function indexOf$1(head, signature) {
|
|
3940
6238
|
for (let start = 0; start + signature.length <= head.length; start++) {
|
|
3941
6239
|
if (signature.every((byte, index) => head[start + index] === byte)) {
|
|
3942
6240
|
return start;
|
|
@@ -3945,7 +6243,7 @@ function indexOf(head, signature) {
|
|
|
3945
6243
|
return -1;
|
|
3946
6244
|
}
|
|
3947
6245
|
|
|
3948
|
-
const dashCase$
|
|
6246
|
+
const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
3949
6247
|
const minQueryLength = 2;
|
|
3950
6248
|
const maxQueryLength = 100;
|
|
3951
6249
|
const defaultLimit = 20;
|
|
@@ -3972,7 +6270,7 @@ class NhAssistantMockSubjects {
|
|
|
3972
6270
|
this.openProposals = openProposals;
|
|
3973
6271
|
const ids = new Set();
|
|
3974
6272
|
for (const type of types) {
|
|
3975
|
-
if (!dashCase$
|
|
6273
|
+
if (!dashCase$4.test(type.id) || type.id.length > 100) {
|
|
3976
6274
|
throw new Error(`Mock subject type '${type.id}' is not a dash-case id of at most 100 characters.`);
|
|
3977
6275
|
}
|
|
3978
6276
|
if (type.id.startsWith('nh-')) {
|
|
@@ -4128,7 +6426,7 @@ class NhAssistantMockSubjects {
|
|
|
4128
6426
|
* subject not found.
|
|
4129
6427
|
*/
|
|
4130
6428
|
work(typeId, subjectId, language) {
|
|
4131
|
-
if (!dashCase$
|
|
6429
|
+
if (!dashCase$4.test(typeId) || typeId.length > 100 || subjectId.trim().length === 0 || subjectId.length > 200 || subjectId.trim() !== subjectId) {
|
|
4132
6430
|
return mockFailure('assistant-subject-invalid');
|
|
4133
6431
|
}
|
|
4134
6432
|
if (typeId === systemType$1) {
|
|
@@ -4245,7 +6543,7 @@ function isValidLinkTemplate(template) {
|
|
|
4245
6543
|
return pathStart > 0 && template.indexOf(placeholder) > pathStart;
|
|
4246
6544
|
}
|
|
4247
6545
|
|
|
4248
|
-
const dashCase$
|
|
6546
|
+
const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
4249
6547
|
const maxMessage = 8_000;
|
|
4250
6548
|
const maxSubjectId = 200;
|
|
4251
6549
|
const maxAgentId = 128;
|
|
@@ -4963,7 +7261,7 @@ function key(agentId, subject) {
|
|
|
4963
7261
|
return `${agentId}|${subject.subjectType}|${subject.subjectId}`;
|
|
4964
7262
|
}
|
|
4965
7263
|
function validSubject(type, id) {
|
|
4966
|
-
return dashCase$
|
|
7264
|
+
return dashCase$3.test(type) && type.length <= 100 && id.trim().length > 0 && id.length <= maxSubjectId && id.trim() === id;
|
|
4967
7265
|
}
|
|
4968
7266
|
/** The type the agent works on, and for the whole application its scope: app-wide or per division. */
|
|
4969
7267
|
function worksOn(agent, subject) {
|
|
@@ -5033,7 +7331,7 @@ function readFilter(query, withAgent) {
|
|
|
5033
7331
|
errors['agentId'] = ['invalid'];
|
|
5034
7332
|
}
|
|
5035
7333
|
const subjectType = query.get('subjectType');
|
|
5036
|
-
if (subjectType !== null && (!dashCase$
|
|
7334
|
+
if (subjectType !== null && (!dashCase$3.test(subjectType) || subjectType.length > 100)) {
|
|
5037
7335
|
errors['subjectType'] = ['invalid'];
|
|
5038
7336
|
}
|
|
5039
7337
|
const subjectId = query.get('subjectId');
|
|
@@ -5063,7 +7361,7 @@ function readFilter(query, withAgent) {
|
|
|
5063
7361
|
return { filter: { agentId, subjectType, subjectId, status, from, to, page, itemsPerPage } };
|
|
5064
7362
|
}
|
|
5065
7363
|
|
|
5066
|
-
const dashCase$
|
|
7364
|
+
const dashCase$2 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
5067
7365
|
const language = /^[A-Za-z]{2,8}(-[A-Za-z0-9]{1,8})*$/;
|
|
5068
7366
|
const maxGroupId = 64;
|
|
5069
7367
|
const maxGroups = 100;
|
|
@@ -5125,6 +7423,13 @@ class NhAssistantMockBoards {
|
|
|
5125
7423
|
}
|
|
5126
7424
|
return { agentId, groups: [...groups].sort(), codeGroups };
|
|
5127
7425
|
}
|
|
7426
|
+
/**
|
|
7427
|
+
* A copy of the administrators' group choices; the returned function puts it back, for an import
|
|
7428
|
+
* of an agent package that fails as a whole and for its preview.
|
|
7429
|
+
*/
|
|
7430
|
+
snapshot() {
|
|
7431
|
+
return mockSnapshot(this.choices);
|
|
7432
|
+
}
|
|
5128
7433
|
/** Forgets the choices about an agent that was deleted. */
|
|
5129
7434
|
forget(agentId) {
|
|
5130
7435
|
this.choices.delete(agentId);
|
|
@@ -5307,7 +7612,7 @@ function validate(groups) {
|
|
|
5307
7612
|
}
|
|
5308
7613
|
const seen = new Set();
|
|
5309
7614
|
for (const group of groups) {
|
|
5310
|
-
if (!dashCase$
|
|
7615
|
+
if (!dashCase$2.test(group.id) || group.id.length > maxGroupId) {
|
|
5311
7616
|
throw new Error(`The mock agent group '${group.id}' is not a dash-case id of at most ${maxGroupId} characters.`);
|
|
5312
7617
|
}
|
|
5313
7618
|
if (seen.has(group.id)) {
|
|
@@ -5333,6 +7638,239 @@ function checkTexts(groupId, kind, texts, fallback, max) {
|
|
|
5333
7638
|
}
|
|
5334
7639
|
}
|
|
5335
7640
|
|
|
7641
|
+
const defaultMaxBytes = 4_096;
|
|
7642
|
+
const dashCase$1 = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7643
|
+
/**
|
|
7644
|
+
* Agent metadata of the mock API (MD1 to MD3), as the server serves it: `admin.agentMetadata` of the
|
|
7645
|
+
* scenario are the entries registered in code, and `metadata` of each scenario agent its values, in
|
|
7646
|
+
* code for an agent in code and stored at the first version for an agent created as data.
|
|
7647
|
+
* `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}` answer with
|
|
7648
|
+
* the server's checks: an entry of an agent created here makes the agent's next version, an editable
|
|
7649
|
+
* entry of an agent in code is an override with a version of its own, and a read-only one is refused.
|
|
7650
|
+
* Values of `POST` and `PUT admin/agents` are not taken: the agent editor saves each entry through its
|
|
7651
|
+
* slot. Values live as long as the mock.
|
|
7652
|
+
*/
|
|
7653
|
+
class NhAssistantMockAgentMetadata {
|
|
7654
|
+
constructor(scenario, context, now) {
|
|
7655
|
+
this.context = context;
|
|
7656
|
+
this.now = now;
|
|
7657
|
+
/** The values in code, by agent and entry. */
|
|
7658
|
+
this.code = new Map();
|
|
7659
|
+
/** The stored values, by agent and entry. */
|
|
7660
|
+
this.stored = new Map();
|
|
7661
|
+
this.entries = [...(scenario.admin?.agentMetadata ?? [])].sort((left, right) => left.name.localeCompare(right.name));
|
|
7662
|
+
for (const entry of this.entries) {
|
|
7663
|
+
if (!dashCase$1.test(entry.name) || entry.name.length > 64 || entry.name.startsWith('nh-')) {
|
|
7664
|
+
throw new Error(`The mock's agent metadata entry '${entry.name}' is not a valid name.`);
|
|
7665
|
+
}
|
|
7666
|
+
}
|
|
7667
|
+
for (const agent of scenario.admin?.agents ?? []) {
|
|
7668
|
+
for (const [name, value] of Object.entries(agent.metadata ?? {})) {
|
|
7669
|
+
const entry = this.entries.find(item => item.name === name);
|
|
7670
|
+
// Startup refuses a value that a save would refuse.
|
|
7671
|
+
const problems = entry ? this.problemsOf(entry, value) : { '': ['not-found'] };
|
|
7672
|
+
if (!entry || Object.keys(problems).length > 0) {
|
|
7673
|
+
throw new Error(`The mock agent '${agent.id}' sets metadata '${name}' that the entries refuse.`);
|
|
7674
|
+
}
|
|
7675
|
+
if (agent.source === 'code') {
|
|
7676
|
+
this.valuesOf(this.code, agent.id).set(name, structuredClone(value));
|
|
7677
|
+
}
|
|
7678
|
+
else {
|
|
7679
|
+
this.valuesOf(this.stored, agent.id).set(name, {
|
|
7680
|
+
value: structuredClone(value),
|
|
7681
|
+
version: 1,
|
|
7682
|
+
changedAt: this.now(),
|
|
7683
|
+
changedBy: 'current-user'
|
|
7684
|
+
});
|
|
7685
|
+
}
|
|
7686
|
+
}
|
|
7687
|
+
}
|
|
7688
|
+
}
|
|
7689
|
+
/** The run catalog's `agentMetadata`. */
|
|
7690
|
+
catalog() {
|
|
7691
|
+
return this.entries.map(entry => ({
|
|
7692
|
+
name: entry.name,
|
|
7693
|
+
editable: entry.editable === true,
|
|
7694
|
+
maxBytes: entry.maxBytes ?? defaultMaxBytes,
|
|
7695
|
+
indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
|
|
7696
|
+
indexedByEach: entry.indexByEach !== undefined
|
|
7697
|
+
}));
|
|
7698
|
+
}
|
|
7699
|
+
/**
|
|
7700
|
+
* A copy of the stored values; the returned function puts it back, for an import of an agent
|
|
7701
|
+
* package that fails as a whole and for its preview.
|
|
7702
|
+
*/
|
|
7703
|
+
snapshot() {
|
|
7704
|
+
return mockSnapshot(this.stored);
|
|
7705
|
+
}
|
|
7706
|
+
/** Forgets the values of an agent that was deleted, as the server removes them with it. */
|
|
7707
|
+
forget(agentId) {
|
|
7708
|
+
this.stored.delete(agentId);
|
|
7709
|
+
}
|
|
7710
|
+
/** `GET admin/agents/{id}/metadata`, `PUT` and `DELETE admin/agents/{id}/metadata/{name}`. */
|
|
7711
|
+
handle(method, agentId, name, query, body) {
|
|
7712
|
+
if (!this.context.administers()) {
|
|
7713
|
+
return mockFailure('assistant-forbidden');
|
|
7714
|
+
}
|
|
7715
|
+
if (name === undefined) {
|
|
7716
|
+
if (method !== 'GET') {
|
|
7717
|
+
return { status: 405 };
|
|
7718
|
+
}
|
|
7719
|
+
const agent = this.context.agent(agentId);
|
|
7720
|
+
return agent
|
|
7721
|
+
? { status: 200, body: this.entries.map(entry => this.view(agent, entry)) }
|
|
7722
|
+
: mockFailure('assistant-agent-not-found');
|
|
7723
|
+
}
|
|
7724
|
+
if (method !== 'PUT' && method !== 'DELETE') {
|
|
7725
|
+
return { status: 405 };
|
|
7726
|
+
}
|
|
7727
|
+
const entry = this.entries.find(item => item.name === name);
|
|
7728
|
+
if (!entry) {
|
|
7729
|
+
return mockFailure('assistant-metadata-entry-not-found');
|
|
7730
|
+
}
|
|
7731
|
+
const agent = this.context.agent(agentId);
|
|
7732
|
+
if (!agent) {
|
|
7733
|
+
return mockFailure('assistant-agent-not-found');
|
|
7734
|
+
}
|
|
7735
|
+
if (agent.source === 'code' && entry.editable !== true) {
|
|
7736
|
+
return mockFailure('assistant-metadata-read-only');
|
|
7737
|
+
}
|
|
7738
|
+
return method === 'PUT'
|
|
7739
|
+
? this.save(agent, entry, body)
|
|
7740
|
+
: this.remove(agent, entry, query.get('expectedVersion'));
|
|
7741
|
+
}
|
|
7742
|
+
save(agent, entry, request) {
|
|
7743
|
+
if (!request || typeof request !== 'object') {
|
|
7744
|
+
return mockFailure('assistant-validation');
|
|
7745
|
+
}
|
|
7746
|
+
const errors = {};
|
|
7747
|
+
for (const [field, codes] of Object.entries(this.problemsOf(entry, request.value))) {
|
|
7748
|
+
errors[field === '' ? 'value' : `value.${field}`] = codes;
|
|
7749
|
+
}
|
|
7750
|
+
const expectedVersion = typeof request.expectedVersion === 'number' ? request.expectedVersion : null;
|
|
7751
|
+
if (agent.source !== 'code' && expectedVersion === null) {
|
|
7752
|
+
errors['expectedVersion'] = ['required'];
|
|
7753
|
+
}
|
|
7754
|
+
if (Object.keys(errors).length > 0) {
|
|
7755
|
+
return mockFailure('assistant-validation', errors);
|
|
7756
|
+
}
|
|
7757
|
+
const values = this.valuesOf(this.stored, agent.id);
|
|
7758
|
+
const current = values.get(entry.name);
|
|
7759
|
+
let version;
|
|
7760
|
+
if (agent.source === 'code') {
|
|
7761
|
+
if (expectedVersion !== (current?.version ?? null)) {
|
|
7762
|
+
return mockFailure('assistant-version-conflict');
|
|
7763
|
+
}
|
|
7764
|
+
version = (current?.version ?? 0) + 1;
|
|
7765
|
+
}
|
|
7766
|
+
else {
|
|
7767
|
+
if (expectedVersion !== agent.version) {
|
|
7768
|
+
return mockFailure('assistant-version-conflict');
|
|
7769
|
+
}
|
|
7770
|
+
version = this.context.nextVersion(agent.id).version;
|
|
7771
|
+
}
|
|
7772
|
+
values.set(entry.name, { value: structuredClone(request.value), version, changedAt: this.now(), changedBy: 'current-user' });
|
|
7773
|
+
return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
|
|
7774
|
+
}
|
|
7775
|
+
remove(agent, entry, expected) {
|
|
7776
|
+
const values = this.valuesOf(this.stored, agent.id);
|
|
7777
|
+
const current = values.get(entry.name);
|
|
7778
|
+
if (!current) {
|
|
7779
|
+
return { status: 200, body: this.view(agent, entry) };
|
|
7780
|
+
}
|
|
7781
|
+
if (expected === null || !/^\d+$/.test(expected)) {
|
|
7782
|
+
return mockFailure('assistant-validation', { expectedVersion: ['required'] });
|
|
7783
|
+
}
|
|
7784
|
+
const expectedVersion = Number(expected);
|
|
7785
|
+
if (expectedVersion !== (agent.source === 'code' ? current.version : agent.version)) {
|
|
7786
|
+
return mockFailure('assistant-version-conflict');
|
|
7787
|
+
}
|
|
7788
|
+
if (agent.source !== 'code') {
|
|
7789
|
+
this.context.nextVersion(agent.id);
|
|
7790
|
+
}
|
|
7791
|
+
values.delete(entry.name);
|
|
7792
|
+
return { status: 200, body: this.view(this.context.agent(agent.id), entry) };
|
|
7793
|
+
}
|
|
7794
|
+
/** The entry of the agent in effect, as `GET admin/agents/{id}/metadata` lists it. */
|
|
7795
|
+
view(agent, entry) {
|
|
7796
|
+
const stored = this.stored.get(agent.id)?.get(entry.name);
|
|
7797
|
+
const code = this.code.get(agent.id)?.get(entry.name);
|
|
7798
|
+
const inCode = agent.source === 'code';
|
|
7799
|
+
const value = stored?.value ?? (inCode ? code : undefined);
|
|
7800
|
+
const source = stored
|
|
7801
|
+
? (inCode ? 'override' : 'data')
|
|
7802
|
+
: (inCode && code !== undefined ? 'code' : null);
|
|
7803
|
+
return {
|
|
7804
|
+
name: entry.name,
|
|
7805
|
+
editable: !inCode || entry.editable === true,
|
|
7806
|
+
maxBytes: entry.maxBytes ?? defaultMaxBytes,
|
|
7807
|
+
source,
|
|
7808
|
+
value: value === undefined ? null : structuredClone(value),
|
|
7809
|
+
version: inCode ? stored?.version ?? null : agent.version,
|
|
7810
|
+
agentVersion: agent.version,
|
|
7811
|
+
codeValue: inCode && code !== undefined ? structuredClone(code) : null,
|
|
7812
|
+
indexed: entry.indexBy !== undefined || entry.indexByEach !== undefined,
|
|
7813
|
+
indexValue: value !== undefined ? indexOf(entry, value) : null,
|
|
7814
|
+
indexedByEach: entry.indexByEach !== undefined,
|
|
7815
|
+
indexValues: value !== undefined ? indexValuesOf(entry, value) : [],
|
|
7816
|
+
changedAt: stored?.changedAt ?? null,
|
|
7817
|
+
changedBy: stored?.changedBy ?? null
|
|
7818
|
+
};
|
|
7819
|
+
}
|
|
7820
|
+
/** The problems of a value, by field path, `''` for the whole value, as the server checks it. */
|
|
7821
|
+
problemsOf(entry, value) {
|
|
7822
|
+
if (value === null || value === undefined) {
|
|
7823
|
+
return { '': ['required'] };
|
|
7824
|
+
}
|
|
7825
|
+
if (typeof value !== 'object' || Array.isArray(value)
|
|
7826
|
+
|| (entry.fields && Object.keys(value).some(field => !entry.fields.includes(field)))) {
|
|
7827
|
+
return { '': ['invalid'] };
|
|
7828
|
+
}
|
|
7829
|
+
if (new TextEncoder().encode(JSON.stringify(value)).length > (entry.maxBytes ?? defaultMaxBytes)) {
|
|
7830
|
+
return { '': ['too-long'] };
|
|
7831
|
+
}
|
|
7832
|
+
const problems = { ...(entry.validate?.(value) ?? {}) };
|
|
7833
|
+
const index = indexOf(entry, value);
|
|
7834
|
+
if (index !== null && index.length > 256) {
|
|
7835
|
+
problems[''] = [...(problems[''] ?? []), 'too-long'];
|
|
7836
|
+
}
|
|
7837
|
+
return Object.fromEntries(Object.entries(problems).filter(([, codes]) => codes.length > 0));
|
|
7838
|
+
}
|
|
7839
|
+
valuesOf(map, agentId) {
|
|
7840
|
+
let values = map.get(agentId);
|
|
7841
|
+
if (!values) {
|
|
7842
|
+
values = new Map();
|
|
7843
|
+
map.set(agentId, values);
|
|
7844
|
+
}
|
|
7845
|
+
return values;
|
|
7846
|
+
}
|
|
7847
|
+
}
|
|
7848
|
+
/** The searchable value of a value, trimmed and in lowercase, as the server stores it. */
|
|
7849
|
+
function indexOf(entry, value) {
|
|
7850
|
+
if (entry.indexBy === undefined) {
|
|
7851
|
+
return null;
|
|
7852
|
+
}
|
|
7853
|
+
const text = value[entry.indexBy];
|
|
7854
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
7855
|
+
return trimmed.length > 0 ? trimmed.toLowerCase() : null;
|
|
7856
|
+
}
|
|
7857
|
+
/**
|
|
7858
|
+
* The searchable values of an `IndexByEach` entry's value as the server stores them: trimmed, in
|
|
7859
|
+
* lowercase, without empty values or repeats, in ordinal order.
|
|
7860
|
+
*/
|
|
7861
|
+
function indexValuesOf(entry, value) {
|
|
7862
|
+
const texts = entry.indexByEach === undefined ? undefined : value[entry.indexByEach];
|
|
7863
|
+
if (!Array.isArray(texts)) {
|
|
7864
|
+
return [];
|
|
7865
|
+
}
|
|
7866
|
+
const values = texts
|
|
7867
|
+
.map(text => (typeof text === 'string' ? text.trim().toLowerCase() : ''))
|
|
7868
|
+
.filter(text => text.length > 0);
|
|
7869
|
+
return [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
7870
|
+
}
|
|
7871
|
+
|
|
7872
|
+
/** How long the application may take to report after a hand-off in the mock. */
|
|
7873
|
+
const reportWithinMs = 4 * 60 * 60 * 1000;
|
|
5336
7874
|
const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
5337
7875
|
const maxReason = 500;
|
|
5338
7876
|
const maxItemsPerPage = 100;
|
|
@@ -5357,7 +7895,10 @@ const fourEyesCode = 'assistant-four-eyes-required';
|
|
|
5357
7895
|
* answers `404` and deciding it `404 assistant-approval-not-found`. A consultation's proposal that the
|
|
5358
7896
|
* person who asked decides in their own chat (`asker-decides`) waits inline and is refused here with
|
|
5359
7897
|
* `assistant-consult-asker-decides`; one that runs with the agent's own rights refuses its asker with
|
|
5360
|
-
* `assistant-four-eyes-required`.
|
|
7898
|
+
* `assistant-four-eyes-required`. A proposal of a tool the application executes (`executedByApplication`)
|
|
7899
|
+
* waits as `awaiting-application` once approved, and `POST approvals/{id}/resolve` resolves an
|
|
7900
|
+
* `execution-unknown` proposal as the server does: `hand-to-application` for one the application
|
|
7901
|
+
* executes, `re-execute` for any other, `mark-resolved` for both.
|
|
5361
7902
|
*/
|
|
5362
7903
|
class NhAssistantMockProposals {
|
|
5363
7904
|
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
|
|
@@ -5383,6 +7924,9 @@ class NhAssistantMockProposals {
|
|
|
5383
7924
|
const route = item.consultationRoute ?? null;
|
|
5384
7925
|
const refusal = consultationRefusal(route, item.initiatorActorId ?? null, actorId());
|
|
5385
7926
|
const canDecide = item.canDecide ?? refusal === null;
|
|
7927
|
+
const executedByApplication = item.executedByApplication ?? false;
|
|
7928
|
+
const handedOff = executedByApplication && (item.status === 'awaiting-application' || item.status === 'execution-unknown');
|
|
7929
|
+
const madeAt = item.createdAt ?? new Date(created).toISOString();
|
|
5386
7930
|
this.proposals.set(item.approvalId, {
|
|
5387
7931
|
proposal: {
|
|
5388
7932
|
approvalId: item.approvalId,
|
|
@@ -5410,8 +7954,11 @@ class NhAssistantMockProposals {
|
|
|
5410
7954
|
initiatorActorId: item.initiatorActorId ?? null,
|
|
5411
7955
|
decidedByActorId: null,
|
|
5412
7956
|
decidedAt: null,
|
|
5413
|
-
executionResultCode: null,
|
|
7957
|
+
executionResultCode: item.executionResultCode ?? null,
|
|
5414
7958
|
executedAt: null,
|
|
7959
|
+
executionReportDueAt: handedOff
|
|
7960
|
+
? item.executionReportDueAt ?? new Date(Date.parse(madeAt) + reportWithinMs).toISOString()
|
|
7961
|
+
: null,
|
|
5415
7962
|
rationale: item.rationale ?? null,
|
|
5416
7963
|
placedBy: item.placedBy ?? null,
|
|
5417
7964
|
parent: item.parent ?? null,
|
|
@@ -5421,7 +7968,8 @@ class NhAssistantMockProposals {
|
|
|
5421
7968
|
},
|
|
5422
7969
|
divisionId: item.divisionId ?? null,
|
|
5423
7970
|
canDecide,
|
|
5424
|
-
reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
|
|
7971
|
+
reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied',
|
|
7972
|
+
executedByApplication
|
|
5425
7973
|
});
|
|
5426
7974
|
}
|
|
5427
7975
|
}
|
|
@@ -5445,6 +7993,9 @@ class NhAssistantMockProposals {
|
|
|
5445
7993
|
if (segments.length === 3 && segments[2] === 'decisions') {
|
|
5446
7994
|
return method === 'POST' ? this.decideOne(segments[1], body) : { status: 405 };
|
|
5447
7995
|
}
|
|
7996
|
+
if (segments.length === 3 && segments[2] === 'resolve') {
|
|
7997
|
+
return method === 'POST' ? this.resolve(segments[1], body) : { status: 405 };
|
|
7998
|
+
}
|
|
5448
7999
|
return null;
|
|
5449
8000
|
}
|
|
5450
8001
|
/** The proposals waiting in a thread, newest first; `null` when the scenario lists none there. */
|
|
@@ -5509,15 +8060,22 @@ class NhAssistantMockProposals {
|
|
|
5509
8060
|
if (divisionId !== null && !guid.test(divisionId)) {
|
|
5510
8061
|
return { status: 400 };
|
|
5511
8062
|
}
|
|
8063
|
+
// What waits for a decision by default; what waits for the application's report or for an
|
|
8064
|
+
// approver to resolve an uncertain execution on request, with the same visibility.
|
|
8065
|
+
const status = query.get('status') || 'pending';
|
|
8066
|
+
if (!isInboxStatus(status)) {
|
|
8067
|
+
return mockFailure('assistant-message-invalid');
|
|
8068
|
+
}
|
|
5512
8069
|
const grouped = query.get('grouped') === 'true';
|
|
5513
8070
|
const items = this.visible()
|
|
5514
|
-
.filter(item => item.proposal.status ===
|
|
8071
|
+
.filter(item => item.proposal.status === status && item.canDecide)
|
|
5515
8072
|
.filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
|
|
5516
8073
|
.map(item => item.proposal)
|
|
5517
8074
|
.filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
|
|
5518
8075
|
&& (agentId === null || proposal.agentId === agentId)
|
|
5519
8076
|
&& (runId === null || proposal.runId === runId))
|
|
5520
|
-
.sort((left, right) => left.
|
|
8077
|
+
.sort((left, right) => inboxSortAt(left, status).localeCompare(inboxSortAt(right, status))
|
|
8078
|
+
|| left.approvalId.localeCompare(right.approvalId));
|
|
5521
8079
|
const ordered = grouped ? groupedOrder(items) : items;
|
|
5522
8080
|
const offset = (page - 1) * itemsPerPage;
|
|
5523
8081
|
const pageItems = ordered.slice(offset, offset + itemsPerPage);
|
|
@@ -5526,6 +8084,7 @@ class NhAssistantMockProposals {
|
|
|
5526
8084
|
const body = {
|
|
5527
8085
|
items: pageItems,
|
|
5528
8086
|
total: items.length,
|
|
8087
|
+
status,
|
|
5529
8088
|
scopeKey,
|
|
5530
8089
|
lastVisitedAt,
|
|
5531
8090
|
newApprovalIds: pageItems
|
|
@@ -5616,16 +8175,59 @@ class NhAssistantMockProposals {
|
|
|
5616
8175
|
return { code: stored.reasonCode ?? 'assistant-approval-policy-denied' };
|
|
5617
8176
|
}
|
|
5618
8177
|
const now = this.now();
|
|
8178
|
+
// A proposal the application executes is handed to it and waits for its report.
|
|
8179
|
+
const handedOff = approve && stored.executedByApplication;
|
|
5619
8180
|
stored.proposal = {
|
|
5620
8181
|
...stored.proposal,
|
|
5621
|
-
status: approve ? 'executed' : 'rejected',
|
|
8182
|
+
status: approve ? (handedOff ? 'awaiting-application' : 'executed') : 'rejected',
|
|
5622
8183
|
decidedByActorId: this.actorId(),
|
|
5623
8184
|
decidedAt: now,
|
|
5624
|
-
executedAt: approve ? now : null
|
|
8185
|
+
executedAt: approve && !handedOff ? now : null,
|
|
8186
|
+
executionReportDueAt: handedOff ? new Date(Date.parse(now) + reportWithinMs).toISOString() : null
|
|
5625
8187
|
};
|
|
5626
8188
|
this.decided(stored.proposal);
|
|
5627
8189
|
return { code: null };
|
|
5628
8190
|
}
|
|
8191
|
+
/**
|
|
8192
|
+
* `POST approvals/{id}/resolve` as the server answers it: only an `execution-unknown` proposal, which
|
|
8193
|
+
* a proposal the application executes resolves with `hand-to-application` or `mark-resolved` and
|
|
8194
|
+
* every other one with `re-execute` or `mark-resolved`. Handed to the application again, it waits
|
|
8195
|
+
* for its report once more; run again, it executes at once.
|
|
8196
|
+
*/
|
|
8197
|
+
resolve(approvalId, body) {
|
|
8198
|
+
const request = body;
|
|
8199
|
+
const action = request?.action;
|
|
8200
|
+
if (action !== 're-execute' && action !== 'hand-to-application' && action !== 'mark-resolved') {
|
|
8201
|
+
return mockFailure('assistant-approval-decision-invalid');
|
|
8202
|
+
}
|
|
8203
|
+
const stored = this.find(approvalId);
|
|
8204
|
+
if (!stored) {
|
|
8205
|
+
return mockFailure('assistant-approval-not-found');
|
|
8206
|
+
}
|
|
8207
|
+
if (stored.proposal.status !== 'execution-unknown') {
|
|
8208
|
+
return mockFailure('assistant-execution-not-uncertain');
|
|
8209
|
+
}
|
|
8210
|
+
if (!stored.canDecide) {
|
|
8211
|
+
return mockFailure(stored.reasonCode ?? 'assistant-approval-policy-denied');
|
|
8212
|
+
}
|
|
8213
|
+
const handedOff = !!stored.proposal.executionReportDueAt;
|
|
8214
|
+
if ((action === 'hand-to-application' && !handedOff) || (action === 're-execute' && handedOff)) {
|
|
8215
|
+
return mockFailure('assistant-approval-decision-invalid');
|
|
8216
|
+
}
|
|
8217
|
+
const now = this.now();
|
|
8218
|
+
stored.proposal = {
|
|
8219
|
+
...stored.proposal,
|
|
8220
|
+
status: action === 'mark-resolved' ? 'resolved' : action === 'hand-to-application' ? 'awaiting-application' : 'executed',
|
|
8221
|
+
decidedByActorId: this.actorId(),
|
|
8222
|
+
executedAt: action === 're-execute' ? now : stored.proposal.executedAt,
|
|
8223
|
+
executionResultCode: action === 'mark-resolved' ? 'resolved-by-approver' : null,
|
|
8224
|
+
executionReportDueAt: action === 'hand-to-application'
|
|
8225
|
+
? new Date(Date.parse(now) + reportWithinMs).toISOString()
|
|
8226
|
+
: stored.proposal.executionReportDueAt
|
|
8227
|
+
};
|
|
8228
|
+
this.decided(stored.proposal);
|
|
8229
|
+
return { status: 200, body: stored.proposal };
|
|
8230
|
+
}
|
|
5629
8231
|
withRight(item) {
|
|
5630
8232
|
const pending = item.proposal.status === 'pending';
|
|
5631
8233
|
return {
|
|
@@ -5646,6 +8248,23 @@ function consultationRefusal(route, initiatorActorId, actorId) {
|
|
|
5646
8248
|
}
|
|
5647
8249
|
return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
|
|
5648
8250
|
}
|
|
8251
|
+
function isInboxStatus(status) {
|
|
8252
|
+
return status === 'pending' || status === 'awaiting-application' || status === 'execution-unknown';
|
|
8253
|
+
}
|
|
8254
|
+
/**
|
|
8255
|
+
* The time the inbox lists a proposal by, as the server: a pending one by its expiry, one handed to the
|
|
8256
|
+
* application by its report deadline, an uncertain execution by its decision.
|
|
8257
|
+
*/
|
|
8258
|
+
function inboxSortAt(proposal, status) {
|
|
8259
|
+
switch (status) {
|
|
8260
|
+
case 'awaiting-application':
|
|
8261
|
+
return proposal.executionReportDueAt ?? proposal.expiresAt;
|
|
8262
|
+
case 'execution-unknown':
|
|
8263
|
+
return proposal.decidedAt ?? proposal.createdAt;
|
|
8264
|
+
default:
|
|
8265
|
+
return proposal.expiresAt;
|
|
8266
|
+
}
|
|
8267
|
+
}
|
|
5649
8268
|
/** The scope of the inbox's visits: `global`, or the scope key of a division. */
|
|
5650
8269
|
function scopeKeyOf(divisionId) {
|
|
5651
8270
|
return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
|
|
@@ -5780,6 +8399,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
|
|
|
5780
8399
|
const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
|
|
5781
8400
|
const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
|
|
5782
8401
|
const defaultUser = { actorId: 'mock-user', displayName: 'You' };
|
|
8402
|
+
/** The tool of `ask_question`, as the server records its call. */
|
|
8403
|
+
const askQuestionToolId = 'assistant.ask-question';
|
|
5783
8404
|
/** When the scenario's consultations started, when they name no time. */
|
|
5784
8405
|
const consultationStart = new Date().toISOString();
|
|
5785
8406
|
class RunStopped extends Error {
|
|
@@ -5795,6 +8416,7 @@ class NhAssistantMockBackend {
|
|
|
5795
8416
|
this.config = inject(NH_ASSISTANT_CONFIG);
|
|
5796
8417
|
this.conversations = new Map();
|
|
5797
8418
|
this.pendingApprovals = new Map();
|
|
8419
|
+
this.pendingQuestions = new Map();
|
|
5798
8420
|
this.runs = new Map();
|
|
5799
8421
|
/** Page context of the latest message per conversation, as the server stores it. */
|
|
5800
8422
|
this.pageContexts = new Map();
|
|
@@ -5822,6 +8444,25 @@ class NhAssistantMockBackend {
|
|
|
5822
8444
|
agents: () => this.admin.allAgents(),
|
|
5823
8445
|
administers: () => this.admin.administers
|
|
5824
8446
|
}, () => new Date().toISOString());
|
|
8447
|
+
this.agentMetadata = new NhAssistantMockAgentMetadata(this.scenario, {
|
|
8448
|
+
agent: agentId => this.admin.findAgent(agentId),
|
|
8449
|
+
administers: () => this.admin.administers,
|
|
8450
|
+
nextVersion: agentId => this.admin.nextAgentVersion(agentId)
|
|
8451
|
+
}, () => new Date().toISOString());
|
|
8452
|
+
/** Agent packages: their endpoints, and the locks and links of every other administration request. */
|
|
8453
|
+
this.agentPackages = new NhAssistantMockAgentPackages({
|
|
8454
|
+
call: (method, path, body) => this.adminCall(method, path, body),
|
|
8455
|
+
codeDefinition: agentId => this.admin.codeDefinition(agentId),
|
|
8456
|
+
snapshot: () => {
|
|
8457
|
+
const restores = [this.admin.snapshot(), this.agentMetadata.snapshot(), this.boards.snapshot()];
|
|
8458
|
+
return () => {
|
|
8459
|
+
for (const restore of restores) {
|
|
8460
|
+
restore();
|
|
8461
|
+
}
|
|
8462
|
+
};
|
|
8463
|
+
},
|
|
8464
|
+
now: () => new Date().toISOString()
|
|
8465
|
+
});
|
|
5825
8466
|
this.canAdministerState = signal(this.admin.administers, ...(ngDevMode ? [{ debugName: "canAdministerState" }] : []));
|
|
5826
8467
|
this.members = new Map();
|
|
5827
8468
|
this.readPositions = new Map();
|
|
@@ -5896,6 +8537,8 @@ class NhAssistantMockBackend {
|
|
|
5896
8537
|
this.conversations.set(copy.id, copy);
|
|
5897
8538
|
this.readPositions.set(copy.id, lastSequence(copy));
|
|
5898
8539
|
}
|
|
8540
|
+
// The packages the host imported before, once its agents and run configurations are in place.
|
|
8541
|
+
this.agentPackages.importPresets(this.scenario.admin?.agentPackages ?? []);
|
|
5899
8542
|
}
|
|
5900
8543
|
/**
|
|
5901
8544
|
* Plays a scripted turn as another person in the conversation, as if a colleague with an
|
|
@@ -5990,6 +8633,17 @@ class NhAssistantMockBackend {
|
|
|
5990
8633
|
setMcpConnectionStatus(serverId, status) {
|
|
5991
8634
|
this.admin.setMcpConnectionStatus(serverId, status);
|
|
5992
8635
|
}
|
|
8636
|
+
/**
|
|
8637
|
+
* Publishes or changes one version of a text asset of the application. A skill or agent that names
|
|
8638
|
+
* this version with another hash then reports `asset-changed`, as after the server's next load.
|
|
8639
|
+
*/
|
|
8640
|
+
setInstructionAsset(asset) {
|
|
8641
|
+
this.admin.instructionAssets.setAsset(asset);
|
|
8642
|
+
}
|
|
8643
|
+
/** Removes one version of a text asset; what names it reports `asset-missing`. */
|
|
8644
|
+
removeInstructionAsset(assetId, version) {
|
|
8645
|
+
this.admin.instructionAssets.removeAsset(assetId, version);
|
|
8646
|
+
}
|
|
5993
8647
|
/** The files people uploaded, oldest first, as the application's file source stored them. */
|
|
5994
8648
|
get uploads() {
|
|
5995
8649
|
return this.files.uploads;
|
|
@@ -6032,23 +8686,15 @@ class NhAssistantMockBackend {
|
|
|
6032
8686
|
if (segments[0] === 'admin' && segments[1] === 'agents' && segments[3] === 'runs' && segments.length === 4) {
|
|
6033
8687
|
return this.result(this.agentRuns.handleAgentRuns(method, segments[2], query, language));
|
|
6034
8688
|
}
|
|
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
8689
|
if (segments[0] === 'admin') {
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
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
|
-
});
|
|
8690
|
+
// Agent packages have endpoints of their own; every other save runs under their locks, and
|
|
8691
|
+
// every read of the administration carries the links of the parts.
|
|
8692
|
+
const packaged = this.agentPackages.handle(method, segments, body);
|
|
8693
|
+
if (packaged) {
|
|
8694
|
+
return this.result(packaged);
|
|
6050
8695
|
}
|
|
6051
|
-
|
|
8696
|
+
const result = this.agentPackages.guard(method, segments, () => this.adminResult(method, segments, query, body, language));
|
|
8697
|
+
return this.result(this.agentPackages.decorate(method, segments, result));
|
|
6052
8698
|
}
|
|
6053
8699
|
if (segments[0] === 'boards') {
|
|
6054
8700
|
return this.result(this.boards.handle(method, segments, query, language) ?? { status: 404 });
|
|
@@ -6090,6 +8736,7 @@ class NhAssistantMockBackend {
|
|
|
6090
8736
|
if (method === 'DELETE') {
|
|
6091
8737
|
this.conversations.delete(conversation.id);
|
|
6092
8738
|
this.pendingApprovals.delete(conversation.id);
|
|
8739
|
+
this.pendingQuestions.delete(conversation.id);
|
|
6093
8740
|
this.emitLive({ kind: 'removed', data: { conversationId: conversation.id } });
|
|
6094
8741
|
return this.empty(204);
|
|
6095
8742
|
}
|
|
@@ -6134,6 +8781,12 @@ class NhAssistantMockBackend {
|
|
|
6134
8781
|
if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'decide') {
|
|
6135
8782
|
return this.decide(conversation, segments[3], body);
|
|
6136
8783
|
}
|
|
8784
|
+
if (method === 'POST' && segments.length === 5 && segments[2] === 'approvals' && segments[4] === 'resolve') {
|
|
8785
|
+
return this.resolve(conversation, segments[3], body);
|
|
8786
|
+
}
|
|
8787
|
+
if (method === 'POST' && segments.length === 5 && segments[2] === 'questions' && segments[4] === 'answer') {
|
|
8788
|
+
return this.answer(conversation, segments[3], body);
|
|
8789
|
+
}
|
|
6137
8790
|
return this.empty(404);
|
|
6138
8791
|
}
|
|
6139
8792
|
list(query) {
|
|
@@ -6277,12 +8930,92 @@ class NhAssistantMockBackend {
|
|
|
6277
8930
|
}
|
|
6278
8931
|
});
|
|
6279
8932
|
}
|
|
8933
|
+
/**
|
|
8934
|
+
* `POST conversations/{id}/approvals/{approvalId}/resolve`: the person who approved a proposal in this
|
|
8935
|
+
* chat resolves its uncertain hand-off (`canResolve`), as the server does: `hand-to-application` hands
|
|
8936
|
+
* it to the application again with a new report deadline, `mark-resolved` confirms it. Another action
|
|
8937
|
+
* is refused, as are an approval in another status and one the caller may not resolve. Returns the
|
|
8938
|
+
* conversation.
|
|
8939
|
+
*/
|
|
8940
|
+
resolve(conversation, approvalId, request) {
|
|
8941
|
+
const part = conversation.messages
|
|
8942
|
+
.flatMap(message => message.parts)
|
|
8943
|
+
.find((item) => item.type === 'approval' && item.approvalId === approvalId);
|
|
8944
|
+
if (!part) {
|
|
8945
|
+
return this.error('assistant-approval-not-found');
|
|
8946
|
+
}
|
|
8947
|
+
const action = request?.action;
|
|
8948
|
+
if (action !== 'hand-to-application' && action !== 'mark-resolved') {
|
|
8949
|
+
return this.error('assistant-approval-decision-invalid');
|
|
8950
|
+
}
|
|
8951
|
+
if (part.status !== 'execution-unknown') {
|
|
8952
|
+
return this.error('assistant-execution-not-uncertain');
|
|
8953
|
+
}
|
|
8954
|
+
if (part.canResolve !== true) {
|
|
8955
|
+
return this.error('assistant-approval-forbidden');
|
|
8956
|
+
}
|
|
8957
|
+
const reportDueAt = new Date(Date.now() + mockReportWithinMs).toISOString();
|
|
8958
|
+
this.update(conversation.id, current => withResolvedApproval(current, approvalId, action, reportDueAt));
|
|
8959
|
+
return this.json(200, this.view(this.conversations.get(conversation.id) ?? conversation));
|
|
8960
|
+
}
|
|
8961
|
+
/**
|
|
8962
|
+
* `POST conversations/{id}/questions/{questionId}/answer`: the addressee answers the question the
|
|
8963
|
+
* turn waits for with the index of a choice, as on the server. Anyone else is refused, as are a
|
|
8964
|
+
* choice the question does not offer and a question whose wait ended, which releases the
|
|
8965
|
+
* conversation. The answer resumes the turn with the step's answered steps.
|
|
8966
|
+
*/
|
|
8967
|
+
answer(conversation, questionId, request) {
|
|
8968
|
+
const pending = this.pendingQuestions.get(conversation.id);
|
|
8969
|
+
const known = conversation.messages.some(message => message.parts.some(part => part.type === 'question' && part.questionId === questionId));
|
|
8970
|
+
if (!known) {
|
|
8971
|
+
return this.error('assistant-question-not-found');
|
|
8972
|
+
}
|
|
8973
|
+
if (pending?.question.questionId === questionId && pending.question.addresseeActorId !== this.currentUser.actorId) {
|
|
8974
|
+
return this.error('assistant-question-forbidden');
|
|
8975
|
+
}
|
|
8976
|
+
if (!pending || pending.question.questionId !== questionId) {
|
|
8977
|
+
return this.error('assistant-question-not-pending');
|
|
8978
|
+
}
|
|
8979
|
+
const choice = request?.choice;
|
|
8980
|
+
if (typeof choice !== 'number' || !Number.isInteger(choice) || choice < 0 || choice >= pending.question.choices.length) {
|
|
8981
|
+
return this.error('assistant-question-answer-invalid');
|
|
8982
|
+
}
|
|
8983
|
+
if (Date.parse(pending.question.expiresAt) <= Date.now()) {
|
|
8984
|
+
this.pendingQuestions.delete(conversation.id);
|
|
8985
|
+
this.update(conversation.id, current => withClosedQuestion(current, questionId, 'expired'));
|
|
8986
|
+
return this.error('assistant-question-expired');
|
|
8987
|
+
}
|
|
8988
|
+
this.pendingQuestions.delete(conversation.id);
|
|
8989
|
+
this.activeActors.set(conversation.id, this.currentUser.actorId);
|
|
8990
|
+
const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
|
|
8991
|
+
this.update(conversation.id, current => applyNhAssistantQuestionAnswer(current, questionId, choice));
|
|
8992
|
+
const answer = pending.question.choices[choice];
|
|
8993
|
+
const steps = typeof pending.step.answered === 'function' ? pending.step.answered(answer, choice) : pending.step.answered;
|
|
8994
|
+
const assistantMessageId = this.nextId();
|
|
8995
|
+
return this.stream(conversation.id, async (emit) => {
|
|
8996
|
+
await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId: lastUser?.id ?? questionId, assistantMessageId } });
|
|
8997
|
+
await emit({
|
|
8998
|
+
type: 'tool.completed',
|
|
8999
|
+
data: { invocationId: questionId, status: 'succeeded', resultCode: null, resultPreview: JSON.stringify({ answer }) }
|
|
9000
|
+
});
|
|
9001
|
+
await emit({
|
|
9002
|
+
type: 'question.answered',
|
|
9003
|
+
data: { ...pending.question, status: 'answered', answerIndex: choice, closedAt: new Date().toISOString() }
|
|
9004
|
+
});
|
|
9005
|
+
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
9006
|
+
});
|
|
9007
|
+
}
|
|
6280
9008
|
cancel(conversation) {
|
|
6281
9009
|
const run = this.runs.get(conversation.id);
|
|
6282
9010
|
if (run) {
|
|
6283
9011
|
run.cancelled = true;
|
|
6284
9012
|
return this.empty(202);
|
|
6285
9013
|
}
|
|
9014
|
+
const question = this.pendingQuestions.get(conversation.id);
|
|
9015
|
+
if (question) {
|
|
9016
|
+
this.pendingQuestions.delete(conversation.id);
|
|
9017
|
+
this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'cancelled'));
|
|
9018
|
+
}
|
|
6286
9019
|
const pending = this.pendingApprovals.get(conversation.id);
|
|
6287
9020
|
if (pending) {
|
|
6288
9021
|
this.pendingApprovals.delete(conversation.id);
|
|
@@ -6333,6 +9066,12 @@ class NhAssistantMockBackend {
|
|
|
6333
9066
|
await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
|
|
6334
9067
|
return;
|
|
6335
9068
|
}
|
|
9069
|
+
else if ('question' in step) {
|
|
9070
|
+
await this.pauseForQuestion(conversationId, step.question, emit);
|
|
9071
|
+
usage.toolCalls++;
|
|
9072
|
+
await emit({ type: 'turn.completed', data: { turnId: this.nextId(), status: 'waiting-for-approval', usage, errorCode: null } });
|
|
9073
|
+
return;
|
|
9074
|
+
}
|
|
6336
9075
|
else if ('error' in step) {
|
|
6337
9076
|
await emit({ type: 'error', data: step.error });
|
|
6338
9077
|
// The mock keeps the conversation usable after a failed turn.
|
|
@@ -6400,6 +9139,31 @@ class NhAssistantMockBackend {
|
|
|
6400
9139
|
this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
|
|
6401
9140
|
await emit({ type: 'approval.required', data: approval });
|
|
6402
9141
|
}
|
|
9142
|
+
/** Records the `ask_question` call and posts the question; the turn then waits for its answer. */
|
|
9143
|
+
async pauseForQuestion(conversationId, step, emit) {
|
|
9144
|
+
const questionId = this.nextId();
|
|
9145
|
+
await emit({
|
|
9146
|
+
type: 'tool.started',
|
|
9147
|
+
data: {
|
|
9148
|
+
invocationId: questionId,
|
|
9149
|
+
toolId: askQuestionToolId,
|
|
9150
|
+
toolVersion: 1,
|
|
9151
|
+
displayName: 'ask_question',
|
|
9152
|
+
argumentsPreview: JSON.stringify({ question: step.question, choices: step.choices })
|
|
9153
|
+
}
|
|
9154
|
+
});
|
|
9155
|
+
const question = {
|
|
9156
|
+
type: 'question',
|
|
9157
|
+
questionId,
|
|
9158
|
+
question: step.question,
|
|
9159
|
+
choices: [...step.choices],
|
|
9160
|
+
addresseeActorId: step.addresseeActorId ?? this.activeActors.get(conversationId) ?? this.currentUser.actorId,
|
|
9161
|
+
expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 900) * 1000).toISOString(),
|
|
9162
|
+
status: 'pending'
|
|
9163
|
+
};
|
|
9164
|
+
this.pendingQuestions.set(conversationId, { question, step });
|
|
9165
|
+
await emit({ type: 'question.asked', data: question });
|
|
9166
|
+
}
|
|
6403
9167
|
stream(conversationId, play) {
|
|
6404
9168
|
const timing = this.scenario.timing ?? {};
|
|
6405
9169
|
const firstDelay = timing.firstEventDelayMs ?? 200;
|
|
@@ -6690,6 +9454,44 @@ class NhAssistantMockBackend {
|
|
|
6690
9454
|
const suffix = this.sequence.toString(16).padStart(12, '0');
|
|
6691
9455
|
return `00000000-0000-4000-8000-${suffix}`;
|
|
6692
9456
|
}
|
|
9457
|
+
/**
|
|
9458
|
+
* The answer of an administration endpoint (`admin/...`) as its area gives it, with the groups of
|
|
9459
|
+
* agents and the run catalog in the request's language, without the package locks and links.
|
|
9460
|
+
*/
|
|
9461
|
+
adminResult(method, segments, query, body, language) {
|
|
9462
|
+
if (segments[1] === 'agents' && segments[3] === 'groups' && segments.length === 4) {
|
|
9463
|
+
return this.boards.handleAdminGroups(method, segments[2], body);
|
|
9464
|
+
}
|
|
9465
|
+
if (segments[1] === 'agents' && segments[3] === 'metadata' && segments.length <= 5) {
|
|
9466
|
+
return this.agentMetadata.handle(method, segments[2], segments[4], query, body);
|
|
9467
|
+
}
|
|
9468
|
+
const result = this.admin.handleAdmin(method, segments, body, query);
|
|
9469
|
+
if (method === 'DELETE' && segments[1] === 'agents' && segments.length === 3 && result.status === 204) {
|
|
9470
|
+
this.boards.forget(segments[2]);
|
|
9471
|
+
this.agentMetadata.forget(segments[2]);
|
|
9472
|
+
this.agentPackages.forget(segments[2]);
|
|
9473
|
+
}
|
|
9474
|
+
// The run catalog lists the subject types and the groups in the request's language, as the
|
|
9475
|
+
// server does; agents carry their groups.
|
|
9476
|
+
if (method === 'GET' && segments.length === 2 && segments[1] === 'run-catalog' && result.status === 200) {
|
|
9477
|
+
return {
|
|
9478
|
+
...result,
|
|
9479
|
+
body: {
|
|
9480
|
+
...result.body,
|
|
9481
|
+
subjectTypes: this.subjects.catalog(language),
|
|
9482
|
+
agentGroups: this.boards.catalog(language),
|
|
9483
|
+
agentMetadata: this.agentMetadata.catalog()
|
|
9484
|
+
}
|
|
9485
|
+
};
|
|
9486
|
+
}
|
|
9487
|
+
return segments[1] === 'agents' && result.status >= 200 && result.status < 300 ? this.withGroups(result) : result;
|
|
9488
|
+
}
|
|
9489
|
+
/** An administration request of the agent packages, as `adminResult` answers it, for a path with its query. */
|
|
9490
|
+
adminCall(method, path, body) {
|
|
9491
|
+
const url = new URL(path, 'http://mock.local/');
|
|
9492
|
+
const segments = url.pathname.slice(1).split('/').map(segment => decodeURIComponent(segment));
|
|
9493
|
+
return this.adminResult(method, segments, url.searchParams, body, null);
|
|
9494
|
+
}
|
|
6693
9495
|
/** An administration answer about agents with each agent's groups, as the server answers it. */
|
|
6694
9496
|
withGroups(result) {
|
|
6695
9497
|
const enrich = (item) => {
|
|
@@ -6705,6 +9507,9 @@ class NhAssistantMockBackend {
|
|
|
6705
9507
|
return { groups: membership.groups, codeGroups: membership.codeGroups };
|
|
6706
9508
|
}
|
|
6707
9509
|
result(result) {
|
|
9510
|
+
if (result.text !== undefined) {
|
|
9511
|
+
return new Response(result.text, { status: result.status, headers: { 'content-type': 'application/json', ...result.headers } });
|
|
9512
|
+
}
|
|
6708
9513
|
return result.body === undefined ? this.empty(result.status) : this.json(result.status, result.body);
|
|
6709
9514
|
}
|
|
6710
9515
|
json(status, value) {
|
|
@@ -6739,6 +9544,51 @@ function withMovedApproval(conversation, approvalId, movedToApprovalId) {
|
|
|
6739
9544
|
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
|
|
6740
9545
|
};
|
|
6741
9546
|
}
|
|
9547
|
+
/** How long the mock's application may take to report after a hand-off. */
|
|
9548
|
+
const mockReportWithinMs = 4 * 60 * 60 * 1000;
|
|
9549
|
+
/**
|
|
9550
|
+
* The approval after its uncertain hand-off was resolved: handed to the application again, waiting for
|
|
9551
|
+
* its report until `reportDueAt`, or resolved. Either way nobody resolves it again.
|
|
9552
|
+
*/
|
|
9553
|
+
function withResolvedApproval(conversation, approvalId, action, reportDueAt) {
|
|
9554
|
+
const resolved = (part) => {
|
|
9555
|
+
if (part.type !== 'approval' || part.approvalId !== approvalId) {
|
|
9556
|
+
return part;
|
|
9557
|
+
}
|
|
9558
|
+
const { canResolve: _resolved, ...rest } = part;
|
|
9559
|
+
return action === 'hand-to-application'
|
|
9560
|
+
? { ...rest, status: 'awaiting-application', executionResultCode: 'hand-off-acknowledged', executionReportDueAt: reportDueAt }
|
|
9561
|
+
: { ...rest, status: 'resolved', executionResultCode: 'resolved-by-approver' };
|
|
9562
|
+
};
|
|
9563
|
+
return {
|
|
9564
|
+
...conversation,
|
|
9565
|
+
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(resolved) }))
|
|
9566
|
+
};
|
|
9567
|
+
}
|
|
9568
|
+
/**
|
|
9569
|
+
* A question that closed without an answer, as the server reads it afterwards: expired or withdrawn
|
|
9570
|
+
* with its stopped turn. Its call ends, and the conversation takes the next message.
|
|
9571
|
+
*/
|
|
9572
|
+
function withClosedQuestion(conversation, questionId, status) {
|
|
9573
|
+
const closedAt = new Date().toISOString();
|
|
9574
|
+
const closed = (part) => {
|
|
9575
|
+
if (part.type === 'question' && part.questionId === questionId) {
|
|
9576
|
+
return { ...part, status, closedAt };
|
|
9577
|
+
}
|
|
9578
|
+
if (part.type === 'tool-call' && part.invocationId === questionId) {
|
|
9579
|
+
return status === 'expired'
|
|
9580
|
+
? { ...part, status: 'failed', resultCode: 'assistant-question-expired' }
|
|
9581
|
+
: { ...part, status: 'not-executed', resultCode: 'assistant-question-cancelled' };
|
|
9582
|
+
}
|
|
9583
|
+
return part;
|
|
9584
|
+
};
|
|
9585
|
+
return {
|
|
9586
|
+
...conversation,
|
|
9587
|
+
status: 'idle',
|
|
9588
|
+
pendingQuestion: null,
|
|
9589
|
+
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
|
|
9590
|
+
};
|
|
9591
|
+
}
|
|
6742
9592
|
/** Like the server: a new message starts in an idle conversation or after a failed turn. */
|
|
6743
9593
|
function acceptsMessage(status) {
|
|
6744
9594
|
return status === 'idle' || status === 'failed';
|