@newheap/platform-ai-chat 0.15.0 → 0.17.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 +13 -0
- package/admin/index.d.ts +106 -13
- package/agent-runs/index.d.ts +147 -4
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +496 -40
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs +297 -30
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +696 -56
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1110 -66
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +148 -9
- package/i18n/nl.json +148 -9
- package/index.d.ts +535 -39
- package/package.json +1 -1
- package/testing/index.d.ts +75 -7
|
@@ -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_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
|
|
3
|
+
import { NH_ASSISTANT_UPLOAD_HEADER, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
|
|
4
4
|
import { Observable, Subject } from 'rxjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -25,6 +25,7 @@ const statusTable = {
|
|
|
25
25
|
'assistant-run-filter-invalid',
|
|
26
26
|
'assistant-board-scope-invalid',
|
|
27
27
|
'assistant-feedback-invalid',
|
|
28
|
+
'assistant-trigger-filter-not-found',
|
|
28
29
|
'assistant-replay-not-shadow',
|
|
29
30
|
'assistant-evaluation-not-variant',
|
|
30
31
|
'assistant-instructions-too-long',
|
|
@@ -39,7 +40,12 @@ const statusTable = {
|
|
|
39
40
|
'assistant-mcp-authorization-invalid',
|
|
40
41
|
'assistant-mcp-pkce-unsupported',
|
|
41
42
|
'assistant-mcp-client-registration-required',
|
|
42
|
-
'assistant-mcp-oauth-not-configured'
|
|
43
|
+
'assistant-mcp-oauth-not-configured',
|
|
44
|
+
'assistant-file-reference-invalid',
|
|
45
|
+
'assistant-file-upload-invalid',
|
|
46
|
+
'assistant-file-too-many-pages',
|
|
47
|
+
'assistant-file-count-limit',
|
|
48
|
+
'assistant-file-token-limit'
|
|
43
49
|
],
|
|
44
50
|
401: [
|
|
45
51
|
'assistant-webhook-signature-invalid'
|
|
@@ -55,7 +61,8 @@ const statusTable = {
|
|
|
55
61
|
'assistant-four-eyes-required',
|
|
56
62
|
'assistant-forbidden',
|
|
57
63
|
'assistant-mcp-credential-mismatch',
|
|
58
|
-
'assistant-mcp-initiator-only'
|
|
64
|
+
'assistant-mcp-initiator-only',
|
|
65
|
+
'assistant-file-forbidden'
|
|
59
66
|
],
|
|
60
67
|
404: [
|
|
61
68
|
'assistant-disabled',
|
|
@@ -79,7 +86,9 @@ const statusTable = {
|
|
|
79
86
|
'assistant-not-found',
|
|
80
87
|
'assistant-context-not-found',
|
|
81
88
|
'assistant-mcp-server-not-found',
|
|
82
|
-
'assistant-mcp-tool-not-found'
|
|
89
|
+
'assistant-mcp-tool-not-found',
|
|
90
|
+
'assistant-files-unavailable',
|
|
91
|
+
'assistant-file-not-found'
|
|
83
92
|
],
|
|
84
93
|
409: [
|
|
85
94
|
'assistant-conversation-busy',
|
|
@@ -123,10 +132,20 @@ const statusTable = {
|
|
|
123
132
|
'assistant-mcp-issuer-changed',
|
|
124
133
|
'assistant-mcp-authorization-not-confirmed',
|
|
125
134
|
'assistant-mcp-connection-not-supported',
|
|
126
|
-
'assistant-mcp-secret-unreadable'
|
|
135
|
+
'assistant-mcp-secret-unreadable',
|
|
136
|
+
'assistant-files-not-accepted'
|
|
127
137
|
],
|
|
128
138
|
413: [
|
|
129
|
-
'assistant-webhook-payload-too-large'
|
|
139
|
+
'assistant-webhook-payload-too-large',
|
|
140
|
+
'assistant-file-too-large'
|
|
141
|
+
],
|
|
142
|
+
415: [
|
|
143
|
+
'assistant-file-type-not-supported',
|
|
144
|
+
'assistant-file-type-not-accepted'
|
|
145
|
+
],
|
|
146
|
+
422: [
|
|
147
|
+
'assistant-file-upload-refused',
|
|
148
|
+
'assistant-file-scan-refused'
|
|
130
149
|
],
|
|
131
150
|
429: [
|
|
132
151
|
'assistant-mcp-too-many-authorizations'
|
|
@@ -142,7 +161,9 @@ const statusTable = {
|
|
|
142
161
|
503: [
|
|
143
162
|
'assistant-proposal-check-failed',
|
|
144
163
|
'assistant-trigger-rolled-back',
|
|
145
|
-
'assistant-mcp-secret-unavailable'
|
|
164
|
+
'assistant-mcp-secret-unavailable',
|
|
165
|
+
'assistant-file-unavailable',
|
|
166
|
+
'assistant-file-upload-failed'
|
|
146
167
|
]
|
|
147
168
|
};
|
|
148
169
|
const statuses = buildStatuses();
|
|
@@ -188,6 +209,16 @@ function mockValidationFailure(field) {
|
|
|
188
209
|
return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
|
|
189
210
|
}
|
|
190
211
|
/** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
|
|
212
|
+
/**
|
|
213
|
+
* The warnings of an agent or skill that classifies its model calls with `classification` on
|
|
214
|
+
* `profile`: `classification-not-permitted` when the scenario lists what the profile and its
|
|
215
|
+
* fallbacks permit and that does not include it, as the server reports it; none without a
|
|
216
|
+
* classification of its own.
|
|
217
|
+
*/
|
|
218
|
+
function mockClassificationWarnings(permitted, profile, classification) {
|
|
219
|
+
const allowed = profile ? permitted?.[profile] : undefined;
|
|
220
|
+
return classification && allowed && !allowed.includes(classification) ? ['classification-not-permitted'] : [];
|
|
221
|
+
}
|
|
191
222
|
function mockHashText(text) {
|
|
192
223
|
let hash = 0x811c9dc5;
|
|
193
224
|
for (let index = 0; index < text.length; index++) {
|
|
@@ -333,7 +364,10 @@ function mockWorksOnCovers(agent, subjectType) {
|
|
|
333
364
|
return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
|
|
334
365
|
}
|
|
335
366
|
|
|
367
|
+
/** The profile of an agent that did not choose one, as the application's chat profile. */
|
|
368
|
+
const mockChatProfile = 'chat';
|
|
336
369
|
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
370
|
+
const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
337
371
|
/**
|
|
338
372
|
* The agents of the mock administration: agents in code and agents created as data, their state,
|
|
339
373
|
* shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
|
|
@@ -367,7 +401,7 @@ class NhAssistantMockAdminAgents {
|
|
|
367
401
|
skills: (skills ?? []).map((skill) => ({
|
|
368
402
|
skillId: skill.skillId,
|
|
369
403
|
description: skill.description,
|
|
370
|
-
runState: 'active',
|
|
404
|
+
runState: skill.startsOff === true ? 'off' : 'active',
|
|
371
405
|
changedAt: null,
|
|
372
406
|
changedBy: null,
|
|
373
407
|
reason: null,
|
|
@@ -468,9 +502,15 @@ class NhAssistantMockAdminAgents {
|
|
|
468
502
|
if (typeof engaged !== 'boolean') {
|
|
469
503
|
return mockValidationFailure();
|
|
470
504
|
}
|
|
471
|
-
|
|
505
|
+
// Releasing never switches on an agent that was off, as on the server: one that was off when
|
|
506
|
+
// it stopped goes back off, and one that is off stays off.
|
|
507
|
+
const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
|
|
508
|
+
const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
|
|
472
509
|
return changed.status === 200
|
|
473
|
-
? {
|
|
510
|
+
? {
|
|
511
|
+
status: 200,
|
|
512
|
+
body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0, runState: stored.agent.runState }
|
|
513
|
+
}
|
|
474
514
|
: changed;
|
|
475
515
|
}
|
|
476
516
|
if (length !== 3) {
|
|
@@ -511,7 +551,7 @@ class NhAssistantMockAdminAgents {
|
|
|
511
551
|
return { status: 405 };
|
|
512
552
|
}
|
|
513
553
|
const request = body;
|
|
514
|
-
if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped') {
|
|
554
|
+
if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped' && request?.state !== 'off') {
|
|
515
555
|
return mockValidationFailure();
|
|
516
556
|
}
|
|
517
557
|
const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
@@ -672,9 +712,9 @@ class NhAssistantMockAdminAgents {
|
|
|
672
712
|
}
|
|
673
713
|
/**
|
|
674
714
|
* A skill defined as data shows among the agent's skills as a skill in shadow by default, as the
|
|
675
|
-
* server shows it
|
|
715
|
+
* server shows it, and off when it was added with `startsOff`; removing it takes its state along.
|
|
676
716
|
*/
|
|
677
|
-
setDataSkill(agentId, skillId, description) {
|
|
717
|
+
setDataSkill(agentId, skillId, description, startsOff = false) {
|
|
678
718
|
const stored = this.agents.get(agentId);
|
|
679
719
|
if (!stored) {
|
|
680
720
|
return;
|
|
@@ -688,9 +728,9 @@ class NhAssistantMockAdminAgents {
|
|
|
688
728
|
: [...others, existing ? { ...existing, description } : {
|
|
689
729
|
skillId,
|
|
690
730
|
description,
|
|
691
|
-
runState: 'active',
|
|
692
|
-
changedAt: null,
|
|
693
|
-
changedBy: null,
|
|
731
|
+
runState: startsOff ? 'off' : 'active',
|
|
732
|
+
changedAt: startsOff ? this.now() : null,
|
|
733
|
+
changedBy: startsOff ? 'current-user' : null,
|
|
694
734
|
reason: null,
|
|
695
735
|
waitingInputs: 0,
|
|
696
736
|
toolCallsToday: 0,
|
|
@@ -766,7 +806,8 @@ class NhAssistantMockAdminAgents {
|
|
|
766
806
|
}
|
|
767
807
|
/**
|
|
768
808
|
* `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
|
|
769
|
-
* collects no waiting events
|
|
809
|
+
* collects no waiting events, a stop cancels no runs and switching an agent off or on starts
|
|
810
|
+
* nothing; the version never changes.
|
|
770
811
|
*/
|
|
771
812
|
agentStateEndpoint(stored, method, body) {
|
|
772
813
|
if (method === 'GET') {
|
|
@@ -779,7 +820,7 @@ class NhAssistantMockAdminAgents {
|
|
|
779
820
|
if (request?.state === undefined || request.state === null) {
|
|
780
821
|
return mockValidationFailure();
|
|
781
822
|
}
|
|
782
|
-
if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped') {
|
|
823
|
+
if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped' && request.state !== 'off') {
|
|
783
824
|
return mockValidationFailure();
|
|
784
825
|
}
|
|
785
826
|
const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
@@ -792,6 +833,8 @@ class NhAssistantMockAdminAgents {
|
|
|
792
833
|
: { status: 200, body: toAgentState(stored.agent) };
|
|
793
834
|
}
|
|
794
835
|
if (stored.agent.runState !== request.state) {
|
|
836
|
+
// A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
|
|
837
|
+
stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
|
|
795
838
|
stored.agent = {
|
|
796
839
|
...stored.agent,
|
|
797
840
|
isEnabled: request.state !== 'stopped',
|
|
@@ -821,6 +864,19 @@ class NhAssistantMockAdminAgents {
|
|
|
821
864
|
return mockFailure('assistant-validation', errors);
|
|
822
865
|
}
|
|
823
866
|
}
|
|
867
|
+
// An agent created here uses a profile the run catalog offers, or keeps the one it has or the
|
|
868
|
+
// chat profile it gets by default; an agent in code takes its own.
|
|
869
|
+
const profile = typeof input?.profile === 'string' ? input.profile.trim() : '';
|
|
870
|
+
if (source === 'admin' && profile.length > 0 && profile !== mockChatProfile
|
|
871
|
+
&& profile !== this.agents.get(input.id)?.agent.profile
|
|
872
|
+
&& !this.rules.profileNames().includes(profile)) {
|
|
873
|
+
return mockFailure('assistant-validation', { profile: ['not-found'] });
|
|
874
|
+
}
|
|
875
|
+
// An agent created here names the classification of its model calls, as a data skill does.
|
|
876
|
+
const classification = chosenClassification(input.dataClassification);
|
|
877
|
+
if (source === 'admin' && classification !== null && !dataClassifications$1.includes(classification)) {
|
|
878
|
+
return mockFailure('assistant-validation', { dataClassification: ['invalid'] });
|
|
879
|
+
}
|
|
824
880
|
return null;
|
|
825
881
|
}
|
|
826
882
|
validAgent(input) {
|
|
@@ -841,18 +897,28 @@ class NhAssistantMockAdminAgents {
|
|
|
841
897
|
* `previous`, and a new agent starts active.
|
|
842
898
|
*/
|
|
843
899
|
toAgent(input, source, version, isOverridden, previous) {
|
|
844
|
-
|
|
845
|
-
|
|
900
|
+
// Only a new agent starts off, created that way here or with StartsOff() in code.
|
|
901
|
+
const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
|
|
902
|
+
const createdOff = previous === undefined && startsOff === true;
|
|
903
|
+
const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
|
|
904
|
+
const classification = chosenClassification(dataClassification);
|
|
905
|
+
return this.withWarnings({
|
|
846
906
|
...definition,
|
|
907
|
+
// An agent created here keeps its profile when the input omits it; code decides for the others.
|
|
908
|
+
profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
|
|
909
|
+
// The same for its classification; an agent in code keeps the one of its scenario.
|
|
910
|
+
dataClassification: source === 'admin' || previous === undefined
|
|
911
|
+
? classification ?? previous?.dataClassification ?? null
|
|
912
|
+
: previous.dataClassification ?? null,
|
|
847
913
|
isEnabled: previous?.isEnabled ?? true,
|
|
848
914
|
version,
|
|
849
915
|
source,
|
|
850
916
|
isOverridden,
|
|
851
917
|
instructionsHash: mockHashText(input.instructions),
|
|
852
918
|
updatedAt: this.now(),
|
|
853
|
-
runState: previous?.runState ?? 'active',
|
|
854
|
-
stateChangedAt: previous?.stateChangedAt ?? null,
|
|
855
|
-
stateChangedBy: previous?.stateChangedBy ?? null,
|
|
919
|
+
runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
|
|
920
|
+
stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
|
|
921
|
+
stateChangedBy: previous?.stateChangedBy ?? (createdOff && source === 'admin' ? 'current-user' : null),
|
|
856
922
|
stateReason: previous?.stateReason ?? null,
|
|
857
923
|
waitingInputs: previous?.waitingInputs ?? 0,
|
|
858
924
|
skills: previous?.skills ?? [],
|
|
@@ -868,9 +934,20 @@ class NhAssistantMockAdminAgents {
|
|
|
868
934
|
? pickWorksOn(previous)
|
|
869
935
|
: mockWorksOnFromInput(input) ?? (previous ? pickWorksOn(previous) : { ...mockUndeclaredWorksOn, worksOnDeclared: true })),
|
|
870
936
|
knownSubjectTypes: previous?.knownSubjectTypes ?? []
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/** The agent with the warnings the server reports for it, from its profile and classification. */
|
|
940
|
+
withWarnings(agent) {
|
|
941
|
+
return {
|
|
942
|
+
...agent,
|
|
943
|
+
warnings: mockClassificationWarnings(this.scenario.admin?.profileClassifications, agent.profile, agent.dataClassification)
|
|
871
944
|
};
|
|
872
945
|
}
|
|
873
946
|
}
|
|
947
|
+
/** A classification name the input chose, or `null` when it chose none. */
|
|
948
|
+
function chosenClassification(value) {
|
|
949
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
950
|
+
}
|
|
874
951
|
function codeAgentFromSummary(agent) {
|
|
875
952
|
return {
|
|
876
953
|
id: agent.id,
|
|
@@ -949,6 +1026,17 @@ const maxUserName = 256;
|
|
|
949
1026
|
const maxPolicy = 256;
|
|
950
1027
|
const maxClientId = 512;
|
|
951
1028
|
const maxAccountLabel = 200;
|
|
1029
|
+
/**
|
|
1030
|
+
* The readers of a server: at most 16, each with at most 16 media types, a tool name of at most 128
|
|
1031
|
+
* characters and a priority from -1000 to 1000.
|
|
1032
|
+
*/
|
|
1033
|
+
const maxReaders = 16;
|
|
1034
|
+
const maxReaderMediaTypes = 16;
|
|
1035
|
+
const maxReaderToolName = 128;
|
|
1036
|
+
const readerPriority = { min: -1000, max: 1000 };
|
|
1037
|
+
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
1038
|
+
const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
1039
|
+
const controlCharacters = /[\u0000-\u001f\u007f]/;
|
|
952
1040
|
/**
|
|
953
1041
|
* The MCP servers of the mock administration and the caller's own connections: secrets are
|
|
954
1042
|
* write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
|
|
@@ -990,6 +1078,12 @@ class NhAssistantMockAdminMcp {
|
|
|
990
1078
|
remoteTools: remoteTools ?? [],
|
|
991
1079
|
tools: new Map()
|
|
992
1080
|
};
|
|
1081
|
+
const readers = readersAfter(input, []);
|
|
1082
|
+
if (readers.length > 0) {
|
|
1083
|
+
// Preconfigured readers were set up after a sync of the server's tools.
|
|
1084
|
+
this.sync(stored);
|
|
1085
|
+
stored.server.readers = readers;
|
|
1086
|
+
}
|
|
993
1087
|
if (oauthModes.includes(input.authMode)) {
|
|
994
1088
|
// A preconfigured OAuth server was confirmed by an administrator before.
|
|
995
1089
|
const found = this.discovery(input.url);
|
|
@@ -1240,7 +1334,8 @@ class NhAssistantMockAdminMcp {
|
|
|
1240
1334
|
...toServerFields(input, stored.server),
|
|
1241
1335
|
...oauthFields(input, stored.server, oauth.authorization),
|
|
1242
1336
|
headlessUse,
|
|
1243
|
-
transport: input.transport ?? stored.server.transport
|
|
1337
|
+
transport: input.transport ?? stored.server.transport,
|
|
1338
|
+
readers: readersAfter(input, stored.server.readers)
|
|
1244
1339
|
};
|
|
1245
1340
|
if (!oauthModes.includes(input.authMode)) {
|
|
1246
1341
|
stored.oauthSecret = null;
|
|
@@ -1444,6 +1539,7 @@ class NhAssistantMockAdminMcp {
|
|
|
1444
1539
|
checkServerFields(input, stored, this.scenario.admin?.policies, add, this.scenario.admin?.mcpAuthModes ?? []);
|
|
1445
1540
|
this.checkCredential(input, stored, add);
|
|
1446
1541
|
this.checkOAuth(input, stored, add);
|
|
1542
|
+
checkReaders(input, stored, add);
|
|
1447
1543
|
return errors;
|
|
1448
1544
|
}
|
|
1449
1545
|
/**
|
|
@@ -1933,9 +2029,97 @@ function newServerState(input) {
|
|
|
1933
2029
|
headlessUse: input.headlessUse ?? 'never',
|
|
1934
2030
|
lastConnectAt: null,
|
|
1935
2031
|
lastConnectCode: null,
|
|
1936
|
-
transport: input.transport ?? 'streamable-http'
|
|
2032
|
+
transport: input.transport ?? 'streamable-http',
|
|
2033
|
+
// A new server has no synced tools, so it gets its readers after a sync.
|
|
2034
|
+
readers: []
|
|
1937
2035
|
};
|
|
1938
2036
|
}
|
|
2037
|
+
/** A media type in lowercase without parameters, or null for a value that is not one. */
|
|
2038
|
+
function normalizeMediaType$1(value) {
|
|
2039
|
+
if (typeof value !== 'string') {
|
|
2040
|
+
return null;
|
|
2041
|
+
}
|
|
2042
|
+
const essence = value.split(';')[0].trim().toLowerCase();
|
|
2043
|
+
return essence.length <= 128 && mediaTypePattern$1.test(essence) ? essence : null;
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* The readers after a valid input, as on the server: omitted or null readers keep the stored ones,
|
|
2047
|
+
* an empty list removes them all, and the media types are stored normalized and once each. The
|
|
2048
|
+
* server returns them by priority, highest first, then by tool name.
|
|
2049
|
+
*/
|
|
2050
|
+
function readersAfter(input, stored) {
|
|
2051
|
+
const readers = input.readers;
|
|
2052
|
+
if (readers === undefined || readers === null) {
|
|
2053
|
+
return stored;
|
|
2054
|
+
}
|
|
2055
|
+
return readers
|
|
2056
|
+
.map(reader => ({
|
|
2057
|
+
mediaTypes: [...new Set(reader.mediaTypes.map(normalizeMediaType$1).filter((mediaType) => mediaType !== null))],
|
|
2058
|
+
toolName: reader.toolName ?? '',
|
|
2059
|
+
maxClassification: reader.maxClassification ?? 'restricted',
|
|
2060
|
+
priority: reader.priority ?? 0
|
|
2061
|
+
}))
|
|
2062
|
+
.sort((left, right) => right.priority - left.priority || compareOrdinal(left.toolName, right.toolName));
|
|
2063
|
+
}
|
|
2064
|
+
/**
|
|
2065
|
+
* The field errors of the readers, as the server reports them: `readers` (too-long for more than
|
|
2066
|
+
* 16) and per reader, with its index: `readers[0].mediaTypes` (required, invalid, too-long for more
|
|
2067
|
+
* than 16), `readers[0].toolName` (required, invalid also for a second reader of the same tool,
|
|
2068
|
+
* not-found when the server has not synced that tool), `readers[0].maxClassification` (required,
|
|
2069
|
+
* invalid) and `readers[0].priority` (invalid outside -1000 to 1000).
|
|
2070
|
+
*/
|
|
2071
|
+
function checkReaders(input, stored, add) {
|
|
2072
|
+
const readers = input.readers;
|
|
2073
|
+
if (!Array.isArray(readers) || readers.length === 0) {
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
if (readers.length > maxReaders) {
|
|
2077
|
+
add('readers', 'too-long');
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
const readTools = new Set();
|
|
2081
|
+
readers.forEach((reader, index) => {
|
|
2082
|
+
const field = (name) => `readers[${index}].${name}`;
|
|
2083
|
+
const mediaTypes = Array.isArray(reader?.mediaTypes) ? reader.mediaTypes.map(normalizeMediaType$1) : [];
|
|
2084
|
+
if (mediaTypes.length === 0) {
|
|
2085
|
+
add(field('mediaTypes'), 'required');
|
|
2086
|
+
}
|
|
2087
|
+
else if (mediaTypes.some(mediaType => mediaType === null)) {
|
|
2088
|
+
add(field('mediaTypes'), 'invalid');
|
|
2089
|
+
}
|
|
2090
|
+
else if (new Set(mediaTypes).size > maxReaderMediaTypes) {
|
|
2091
|
+
add(field('mediaTypes'), 'too-long');
|
|
2092
|
+
}
|
|
2093
|
+
const toolName = reader?.toolName;
|
|
2094
|
+
if (typeof toolName !== 'string' || toolName.trim().length === 0) {
|
|
2095
|
+
add(field('toolName'), 'required');
|
|
2096
|
+
}
|
|
2097
|
+
else if (toolName.length > maxReaderToolName || controlCharacters.test(toolName) || readTools.has(toolName)) {
|
|
2098
|
+
add(field('toolName'), 'invalid');
|
|
2099
|
+
}
|
|
2100
|
+
else if (!stored?.tools.has(toolName)) {
|
|
2101
|
+
add(field('toolName'), 'not-found');
|
|
2102
|
+
}
|
|
2103
|
+
if (typeof toolName === 'string') {
|
|
2104
|
+
readTools.add(toolName);
|
|
2105
|
+
}
|
|
2106
|
+
const classification = reader?.maxClassification;
|
|
2107
|
+
if (classification === null || classification === undefined) {
|
|
2108
|
+
add(field('maxClassification'), 'required');
|
|
2109
|
+
}
|
|
2110
|
+
else if (!classifications.includes(classification)) {
|
|
2111
|
+
add(field('maxClassification'), 'invalid');
|
|
2112
|
+
}
|
|
2113
|
+
const priority = reader?.priority ?? 0;
|
|
2114
|
+
if (!Number.isInteger(priority) || priority < readerPriority.min || priority > readerPriority.max) {
|
|
2115
|
+
add(field('priority'), 'invalid');
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
/** Compares two names by their characters, as the server orders them. */
|
|
2120
|
+
function compareOrdinal(left, right) {
|
|
2121
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2122
|
+
}
|
|
1939
2123
|
/**
|
|
1940
2124
|
* `organization` needs a credential of the organization; `personal` is the forwarded token of the
|
|
1941
2125
|
* person a run acts for.
|
|
@@ -2032,12 +2216,24 @@ function kebab(value) {
|
|
|
2032
2216
|
}
|
|
2033
2217
|
|
|
2034
2218
|
const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2219
|
+
const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2035
2220
|
const accountabilities = ['agent', 'subject-responsible', 'service-account'];
|
|
2036
2221
|
const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
|
|
2037
2222
|
const maxDescription$1 = 300;
|
|
2038
2223
|
const maxInstructions = 20_000;
|
|
2039
2224
|
const maxMessage$1 = 2_000;
|
|
2040
2225
|
const maxReason$1 = 500;
|
|
2226
|
+
/** A data trigger applies at most this many filters of its event, each named in lowercase dash-case. */
|
|
2227
|
+
const maxTriggerFilters = 10;
|
|
2228
|
+
const filterName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2229
|
+
/** The platform's default file limits (`NhAssistantLimits`). */
|
|
2230
|
+
const defaultFileLimits = {
|
|
2231
|
+
maxFileBytes: 20 * 1024 * 1024,
|
|
2232
|
+
maxFilePages: 100,
|
|
2233
|
+
maxFilesPerTurn: 5,
|
|
2234
|
+
maxFileTokensPerTurn: 100_000
|
|
2235
|
+
};
|
|
2236
|
+
const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'];
|
|
2041
2237
|
/**
|
|
2042
2238
|
* In-memory run configuration as data of the mock API: run settings of agents created in the
|
|
2043
2239
|
* administration, skills defined as data with versions and candidates, and data triggers. Saving
|
|
@@ -2057,7 +2253,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2057
2253
|
this.triggers = new Map();
|
|
2058
2254
|
const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
|
|
2059
2255
|
this.catalog = {
|
|
2060
|
-
|
|
2256
|
+
// A trigger event lists its named filters, none unless the scenario's application registers some.
|
|
2257
|
+
triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
|
|
2061
2258
|
subjectSets: scenario.runCatalog?.subjectSets ?? [],
|
|
2062
2259
|
ruleHandlers: scenario.runCatalog?.ruleHandlers ?? [],
|
|
2063
2260
|
approvalPolicies,
|
|
@@ -2072,13 +2269,16 @@ class NhAssistantMockRunConfiguration {
|
|
|
2072
2269
|
})),
|
|
2073
2270
|
accountSearch: accounts.searchable,
|
|
2074
2271
|
agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
|
|
2075
|
-
divisions: scenario.runCatalog?.divisions ?? false
|
|
2272
|
+
divisions: scenario.runCatalog?.divisions ?? false,
|
|
2273
|
+
// The platform's default file limits, unless the scenario's application chose its own.
|
|
2274
|
+
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
|
|
2076
2275
|
};
|
|
2077
2276
|
this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
|
|
2078
2277
|
...trigger,
|
|
2079
2278
|
skillId: trigger.skillId ?? null,
|
|
2080
2279
|
subjectType: trigger.subjectType ?? null
|
|
2081
2280
|
}));
|
|
2281
|
+
this.profileClassifications = scenario.profileClassifications;
|
|
2082
2282
|
this.codeRunSettings = new Map((scenario.agents ?? [])
|
|
2083
2283
|
.filter(agent => agent.source === 'code')
|
|
2084
2284
|
.map(agent => [agent.id, agent.codeRunSettings ?? {}]));
|
|
@@ -2091,6 +2291,10 @@ class NhAssistantMockRunConfiguration {
|
|
|
2091
2291
|
get divisionsListed() {
|
|
2092
2292
|
return this.catalog.divisions === true;
|
|
2093
2293
|
}
|
|
2294
|
+
/** The profiles the run catalog offers to data skills and to agents created in the administration. */
|
|
2295
|
+
get profileNames() {
|
|
2296
|
+
return [...this.catalog.profiles];
|
|
2297
|
+
}
|
|
2094
2298
|
/** `GET admin/run-catalog`. */
|
|
2095
2299
|
runCatalog(method) {
|
|
2096
2300
|
return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
|
|
@@ -2164,7 +2368,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2164
2368
|
accountability: given.accountability ?? (agent.serviceAccount ? 'service-account' : 'agent'),
|
|
2165
2369
|
delegateFromInitiator: given.delegateFromInitiator ?? false,
|
|
2166
2370
|
approvalPolicy: given.approvalPolicy ?? null,
|
|
2167
|
-
usesRules: given.usesRules ?? false
|
|
2371
|
+
usesRules: given.usesRules ?? false,
|
|
2372
|
+
startsOff: given.startsOff ?? false,
|
|
2373
|
+
acceptsFiles: given.acceptsFiles ?? false
|
|
2168
2374
|
};
|
|
2169
2375
|
}
|
|
2170
2376
|
/**
|
|
@@ -2174,8 +2380,13 @@ class NhAssistantMockRunConfiguration {
|
|
|
2174
2380
|
*/
|
|
2175
2381
|
triggerProblem(agent, trigger) {
|
|
2176
2382
|
if (trigger.source === 'event') {
|
|
2177
|
-
const
|
|
2178
|
-
|
|
2383
|
+
const event = this.catalog.triggerEvents.find(item => item.name === trigger.event);
|
|
2384
|
+
if (event?.subjectType && !mockWorksOnCovers(agent, event.subjectType)) {
|
|
2385
|
+
return 'not-worked-on';
|
|
2386
|
+
}
|
|
2387
|
+
// A filter the event no longer offers keeps the trigger from firing.
|
|
2388
|
+
const offered = (event?.filters ?? []).map(filter => filter.name);
|
|
2389
|
+
return (trigger.filters ?? []).some(name => !offered.includes(name)) ? 'unknown-filter' : null;
|
|
2179
2390
|
}
|
|
2180
2391
|
return trigger.subjectSet === null && !mockWorksOnCovers(agent, mockSystemSubjectType) ? 'not-worked-on' : null;
|
|
2181
2392
|
}
|
|
@@ -2227,6 +2438,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2227
2438
|
this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
|
|
2228
2439
|
this.requireKnown(errors, 'triageProfile', request.triageProfile, this.catalog.profiles);
|
|
2229
2440
|
this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
|
|
2441
|
+
this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
|
|
2230
2442
|
if (Object.keys(errors).length > 0) {
|
|
2231
2443
|
return mockFailure('assistant-validation', errors);
|
|
2232
2444
|
}
|
|
@@ -2256,7 +2468,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2256
2468
|
changedBy: 'current-user',
|
|
2257
2469
|
reason: text(request.reason),
|
|
2258
2470
|
inEffect: true,
|
|
2259
|
-
problem: null
|
|
2471
|
+
problem: null,
|
|
2472
|
+
acceptsFiles: request.acceptsFiles ?? false,
|
|
2473
|
+
...storedFileLimits(request)
|
|
2260
2474
|
};
|
|
2261
2475
|
this.settings.set(agent.id, saved);
|
|
2262
2476
|
return { status: 200, body: saved };
|
|
@@ -2316,9 +2530,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
2316
2530
|
if (request.apply === 'shadow' && (request.expectedVersion ?? null) === null) {
|
|
2317
2531
|
errors['apply'] = ['invalid'];
|
|
2318
2532
|
}
|
|
2533
|
+
// Only a new skill starts off; a skill that exists keeps its state.
|
|
2534
|
+
if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
|
|
2535
|
+
errors['startsOff'] = ['invalid'];
|
|
2536
|
+
}
|
|
2537
|
+
const classification = text(request.dataClassification);
|
|
2538
|
+
if (classification !== null && !dataClassifications.includes(classification)) {
|
|
2539
|
+
errors['dataClassification'] = ['invalid'];
|
|
2540
|
+
}
|
|
2319
2541
|
this.requireKnown(errors, 'profile', request.profile, this.catalog.profiles);
|
|
2320
2542
|
this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
|
|
2321
2543
|
this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
|
|
2544
|
+
this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
|
|
2322
2545
|
if (Object.keys(errors).length > 0) {
|
|
2323
2546
|
return mockFailure('assistant-validation', errors);
|
|
2324
2547
|
}
|
|
@@ -2344,6 +2567,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2344
2567
|
instructionsHash: instructions === null ? null : hashText(instructions),
|
|
2345
2568
|
tools: [...new Set(request.tools ?? [])],
|
|
2346
2569
|
profile: text(request.profile),
|
|
2570
|
+
dataClassification: classification,
|
|
2571
|
+
acceptsFiles: request.acceptsFiles ?? false,
|
|
2572
|
+
...storedFileLimits(request),
|
|
2347
2573
|
ruleHandler: text(request.ruleHandler),
|
|
2348
2574
|
maxTokensPerRun: request.maxTokensPerRun ?? null,
|
|
2349
2575
|
dailyToolCalls: request.dailyToolCalls ?? null,
|
|
@@ -2357,9 +2583,10 @@ class NhAssistantMockRunConfiguration {
|
|
|
2357
2583
|
};
|
|
2358
2584
|
this.skills.set(key$1(agent.id, skillId), [...versions, saved]);
|
|
2359
2585
|
if (!candidate) {
|
|
2360
|
-
this.agents.setDataSkill(agent.id, skillId, saved.description);
|
|
2586
|
+
this.agents.setDataSkill(agent.id, skillId, saved.description, live === undefined && request.startsOff === true);
|
|
2361
2587
|
}
|
|
2362
|
-
|
|
2588
|
+
// A classification that no profile permits never refuses the save; the skill reports it.
|
|
2589
|
+
return { status: 200, body: this.withWarnings(agent, toSkill(saved)) };
|
|
2363
2590
|
}
|
|
2364
2591
|
candidate(method, agent, skillId, action, body) {
|
|
2365
2592
|
const versions = this.skills.get(key$1(agent.id, skillId)) ?? [];
|
|
@@ -2403,7 +2630,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2403
2630
|
}
|
|
2404
2631
|
candidate.status = 'live';
|
|
2405
2632
|
this.agents.setDataSkill(agent.id, skillId, candidate.description);
|
|
2406
|
-
return { status: 200, body: toSkill(candidate) };
|
|
2633
|
+
return { status: 200, body: this.withWarnings(agent, toSkill(candidate)) };
|
|
2407
2634
|
}
|
|
2408
2635
|
versions(agentId, skillId) {
|
|
2409
2636
|
return [...(this.skills.get(key$1(agentId, skillId)) ?? [])]
|
|
@@ -2489,9 +2716,20 @@ class NhAssistantMockRunConfiguration {
|
|
|
2489
2716
|
if (skill !== null && !this.skillIds(agent).includes(skill)) {
|
|
2490
2717
|
errors['skill'] = ['not-found'];
|
|
2491
2718
|
}
|
|
2719
|
+
// The filters in the order chosen, each once; only an event trigger takes them.
|
|
2720
|
+
const filters = [...new Set((request.filters ?? []).map(name => (typeof name === 'string' ? name.trim() : '')))];
|
|
2721
|
+
if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
|
|
2722
|
+
errors['filters'] = ['invalid'];
|
|
2723
|
+
}
|
|
2492
2724
|
if (Object.keys(errors).length > 0) {
|
|
2493
2725
|
return mockFailure('assistant-validation', errors);
|
|
2494
2726
|
}
|
|
2727
|
+
if (request.source === 'event') {
|
|
2728
|
+
const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
|
|
2729
|
+
if (filters.some(name => !offered.includes(name))) {
|
|
2730
|
+
return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2495
2733
|
if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
|
|
2496
2734
|
return mockFailure('assistant-version-conflict');
|
|
2497
2735
|
}
|
|
@@ -2512,7 +2750,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2512
2750
|
changedBy: 'current-user',
|
|
2513
2751
|
reason: text(request.reason),
|
|
2514
2752
|
inEffect: true,
|
|
2515
|
-
problem: null
|
|
2753
|
+
problem: null,
|
|
2754
|
+
filters: schedule ? [] : filters
|
|
2516
2755
|
};
|
|
2517
2756
|
this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
|
|
2518
2757
|
return { status: 200, body: withoutOwner(saved) };
|
|
@@ -2547,6 +2786,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2547
2786
|
}
|
|
2548
2787
|
liveSkills(agentId) {
|
|
2549
2788
|
const result = [];
|
|
2789
|
+
const agent = this.agents.find(agentId);
|
|
2550
2790
|
for (const [stored, versions] of this.skills) {
|
|
2551
2791
|
if (!stored.startsWith(`${agentId}/`)) {
|
|
2552
2792
|
continue;
|
|
@@ -2554,11 +2794,24 @@ class NhAssistantMockRunConfiguration {
|
|
|
2554
2794
|
const live = versions.find(version => version.status === 'live');
|
|
2555
2795
|
if (live) {
|
|
2556
2796
|
const candidate = versions.find(version => version.status === 'candidate');
|
|
2557
|
-
result.push({
|
|
2797
|
+
result.push({
|
|
2798
|
+
...this.withWarnings(agent, toSkill(live)),
|
|
2799
|
+
candidate: candidate ? this.withWarnings(agent, toSkill(candidate)) : null
|
|
2800
|
+
});
|
|
2558
2801
|
}
|
|
2559
2802
|
}
|
|
2560
2803
|
return result.sort((left, right) => left.skillId.localeCompare(right.skillId));
|
|
2561
2804
|
}
|
|
2805
|
+
/**
|
|
2806
|
+
* The skill with the warnings the server reports for it: its classification, or else its agent's,
|
|
2807
|
+
* that its profile, or else its agent's, does not permit.
|
|
2808
|
+
*/
|
|
2809
|
+
withWarnings(agent, skill) {
|
|
2810
|
+
return {
|
|
2811
|
+
...skill,
|
|
2812
|
+
warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2562
2815
|
ownerOf(trigger) {
|
|
2563
2816
|
return trigger[ownerKey];
|
|
2564
2817
|
}
|
|
@@ -2574,6 +2827,28 @@ class NhAssistantMockRunConfiguration {
|
|
|
2574
2827
|
errors[field] = ['not-found'];
|
|
2575
2828
|
}
|
|
2576
2829
|
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Lower file limits as the server checks them: each a whole number from 1 to the application's
|
|
2832
|
+
* limit, and only next to `acceptsFiles`.
|
|
2833
|
+
*/
|
|
2834
|
+
requireFileLimits(errors, acceptsFiles, request) {
|
|
2835
|
+
const application = this.catalog.fileLimits ?? defaultFileLimits;
|
|
2836
|
+
for (const field of fileLimitFields) {
|
|
2837
|
+
const value = request[field];
|
|
2838
|
+
if (value !== null && value !== undefined && (!acceptsFiles || !inRange(value, 1, application[field]))) {
|
|
2839
|
+
errors[field] = ['invalid'];
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
/** The lower file limits a save stores, `null` where the request keeps the application's. */
|
|
2845
|
+
function storedFileLimits(request) {
|
|
2846
|
+
return {
|
|
2847
|
+
maxFileBytes: request.maxFileBytes ?? null,
|
|
2848
|
+
maxFilePages: request.maxFilePages ?? null,
|
|
2849
|
+
maxFilesPerTurn: request.maxFilesPerTurn ?? null,
|
|
2850
|
+
maxFileTokensPerTurn: request.maxFileTokensPerTurn ?? null
|
|
2851
|
+
};
|
|
2577
2852
|
}
|
|
2578
2853
|
const ownerKey = '__agentId';
|
|
2579
2854
|
function key$1(agentId, skillId) {
|
|
@@ -2649,12 +2924,13 @@ class NhAssistantMockAdmin {
|
|
|
2649
2924
|
this.agents = new NhAssistantMockAdminAgents(scenario, now, {
|
|
2650
2925
|
hasServer: serverId => this.mcp.hasServer(serverId),
|
|
2651
2926
|
agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
|
|
2652
|
-
divisionsListed: () => this.runConfiguration.divisionsListed
|
|
2927
|
+
divisionsListed: () => this.runConfiguration.divisionsListed,
|
|
2928
|
+
profileNames: () => this.runConfiguration.profileNames
|
|
2653
2929
|
});
|
|
2654
2930
|
this.accounts = new NhAssistantMockAccounts(admin);
|
|
2655
2931
|
this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
|
|
2656
2932
|
find: agentId => this.agents.find(agentId),
|
|
2657
|
-
setDataSkill: (agentId, skillId, description) => this.agents.setDataSkill(agentId, skillId, description)
|
|
2933
|
+
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
|
|
2658
2934
|
}, now, this.accounts);
|
|
2659
2935
|
this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
|
|
2660
2936
|
agents: () => this.agents.all(),
|
|
@@ -2859,6 +3135,159 @@ function toContext(version) {
|
|
|
2859
3135
|
return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
|
|
2860
3136
|
}
|
|
2861
3137
|
|
|
3138
|
+
/** The longest file name a reference takes, as on the server. */
|
|
3139
|
+
const maxNameLength = 256;
|
|
3140
|
+
const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
3141
|
+
/** How files of the platform's types start; other accepted types are the application's to check. */
|
|
3142
|
+
const signatures = {
|
|
3143
|
+
'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
3144
|
+
'image/png': head => startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
3145
|
+
'image/jpeg': head => startsWith(head, [0xff, 0xd8, 0xff]),
|
|
3146
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04]),
|
|
3147
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04])
|
|
3148
|
+
};
|
|
3149
|
+
/**
|
|
3150
|
+
* The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
|
|
3151
|
+
* attachments of a message, which a turn opens again as the server's file source would.
|
|
3152
|
+
*/
|
|
3153
|
+
class NhAssistantMockFiles {
|
|
3154
|
+
constructor(refuse, scan) {
|
|
3155
|
+
this.refuse = refuse;
|
|
3156
|
+
this.scan = scan;
|
|
3157
|
+
this.stored = new Map();
|
|
3158
|
+
this.next = 0;
|
|
3159
|
+
}
|
|
3160
|
+
/** The uploads the mock stored, oldest first. */
|
|
3161
|
+
get uploads() {
|
|
3162
|
+
return [...this.stored.values()];
|
|
3163
|
+
}
|
|
3164
|
+
/**
|
|
3165
|
+
* Checks an upload as the server does, in its order: a file source, an agent that accepts files,
|
|
3166
|
+
* the upload header, one file part named `file` with a name and content, an accepted media type
|
|
3167
|
+
* whose content matches, the agent's largest size, the application's scanner, and finally the
|
|
3168
|
+
* application's own refusal.
|
|
3169
|
+
*/
|
|
3170
|
+
async upload(conversationId, offered, uploads, headers, form) {
|
|
3171
|
+
if (!offered) {
|
|
3172
|
+
return mockFailure('assistant-files-unavailable');
|
|
3173
|
+
}
|
|
3174
|
+
if (!uploads) {
|
|
3175
|
+
return mockFailure('assistant-files-not-accepted');
|
|
3176
|
+
}
|
|
3177
|
+
const header = Object.entries(headers).find(([name]) => name.toLowerCase() === NH_ASSISTANT_UPLOAD_HEADER.toLowerCase());
|
|
3178
|
+
let parts = 0;
|
|
3179
|
+
form.forEach(() => parts++);
|
|
3180
|
+
const file = form.get('file');
|
|
3181
|
+
if (!header?.[1] || parts !== 1 || !(file instanceof Blob)) {
|
|
3182
|
+
return mockFailure('assistant-file-upload-invalid');
|
|
3183
|
+
}
|
|
3184
|
+
const name = nameOf(file instanceof File ? file.name : '');
|
|
3185
|
+
if (name === null || file.size === 0) {
|
|
3186
|
+
return mockFailure('assistant-file-upload-invalid');
|
|
3187
|
+
}
|
|
3188
|
+
const mediaType = normalizeMediaType(file.type);
|
|
3189
|
+
if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
|
|
3190
|
+
return mockFailure('assistant-file-type-not-accepted');
|
|
3191
|
+
}
|
|
3192
|
+
if (file.size > uploads.maxFileBytes) {
|
|
3193
|
+
return mockFailure('assistant-file-too-large');
|
|
3194
|
+
}
|
|
3195
|
+
const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
|
|
3196
|
+
if (!(signatures[mediaType]?.(head) ?? true)) {
|
|
3197
|
+
return mockFailure('assistant-file-type-not-accepted');
|
|
3198
|
+
}
|
|
3199
|
+
const upload = { conversationId, name, mediaType, size: file.size };
|
|
3200
|
+
// The application's scanner sees the file before its file source stores anything.
|
|
3201
|
+
const scanned = this.scan?.(upload) ?? null;
|
|
3202
|
+
if (scanned) {
|
|
3203
|
+
return mockFailure(scanned === 'refused' ? 'assistant-file-scan-refused' : 'assistant-file-upload-failed');
|
|
3204
|
+
}
|
|
3205
|
+
const refusal = this.refuse?.(upload) ?? null;
|
|
3206
|
+
if (refusal) {
|
|
3207
|
+
return mockFailure(refusalCode(refusal));
|
|
3208
|
+
}
|
|
3209
|
+
this.next++;
|
|
3210
|
+
const reference = { id: `mock-file-${this.next}`, name, mediaType, size: file.size, hash: null };
|
|
3211
|
+
this.stored.set(reference.id, { ...upload, reference });
|
|
3212
|
+
return { status: 200, body: reference };
|
|
3213
|
+
}
|
|
3214
|
+
/**
|
|
3215
|
+
* The code the server refuses a message's `files` with before the turn starts, or `null`: without
|
|
3216
|
+
* a file source, more files than one message carries, or a reference that is not one.
|
|
3217
|
+
*/
|
|
3218
|
+
checkMessage(files, offered, maxFiles) {
|
|
3219
|
+
if (files === undefined || files === null || (Array.isArray(files) && files.length === 0)) {
|
|
3220
|
+
return null;
|
|
3221
|
+
}
|
|
3222
|
+
if (!Array.isArray(files)) {
|
|
3223
|
+
return 'assistant-file-reference-invalid';
|
|
3224
|
+
}
|
|
3225
|
+
if (!offered) {
|
|
3226
|
+
return 'assistant-files-unavailable';
|
|
3227
|
+
}
|
|
3228
|
+
if (files.length > maxFiles) {
|
|
3229
|
+
return 'assistant-file-count-limit';
|
|
3230
|
+
}
|
|
3231
|
+
return files.every(isReference) ? null : 'assistant-file-reference-invalid';
|
|
3232
|
+
}
|
|
3233
|
+
/**
|
|
3234
|
+
* The code of the first attachment the turn cannot open, as the server's file source refuses it,
|
|
3235
|
+
* or `null` when it opens them all. A reference the mock never stored, or one of another
|
|
3236
|
+
* conversation, is not found.
|
|
3237
|
+
*/
|
|
3238
|
+
openCode(conversationId, files) {
|
|
3239
|
+
const missing = files.find(file => this.stored.get(file.id)?.conversationId !== conversationId);
|
|
3240
|
+
return missing ? 'assistant-file-not-found' : null;
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
/** The file name without a path; `null` when nothing usable is left, as on the server. */
|
|
3244
|
+
function nameOf(fileName) {
|
|
3245
|
+
const name = fileName.slice(Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')) + 1).trim();
|
|
3246
|
+
return name.length === 0 || name.length > maxNameLength || /[\u0000-\u001f\u007f]/.test(name) ? null : name;
|
|
3247
|
+
}
|
|
3248
|
+
function normalizeMediaType(value) {
|
|
3249
|
+
const essence = value.split(';')[0].trim().toLowerCase();
|
|
3250
|
+
return essence.length <= 128 && mediaTypePattern.test(essence) ? essence : null;
|
|
3251
|
+
}
|
|
3252
|
+
function isReference(value) {
|
|
3253
|
+
const file = value;
|
|
3254
|
+
return !!file
|
|
3255
|
+
&& isText(file.id)
|
|
3256
|
+
&& isText(file.name)
|
|
3257
|
+
&& typeof file.mediaType === 'string' && normalizeMediaType(file.mediaType) !== null
|
|
3258
|
+
&& typeof file.size === 'number' && file.size >= 0;
|
|
3259
|
+
}
|
|
3260
|
+
function isText(value) {
|
|
3261
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxNameLength && !/[\u0000-\u001f\u007f]/.test(value);
|
|
3262
|
+
}
|
|
3263
|
+
/** The application's refusal as the server passes it on. */
|
|
3264
|
+
function refusalCode(code) {
|
|
3265
|
+
switch (code) {
|
|
3266
|
+
case 'assistant-files-unavailable':
|
|
3267
|
+
case 'assistant-file-too-large':
|
|
3268
|
+
return code;
|
|
3269
|
+
case 'assistant-file-type-not-accepted':
|
|
3270
|
+
case 'assistant-file-type-not-supported':
|
|
3271
|
+
return 'assistant-file-type-not-accepted';
|
|
3272
|
+
case 'assistant-file-upload-failed':
|
|
3273
|
+
case 'assistant-file-unavailable':
|
|
3274
|
+
return 'assistant-file-upload-failed';
|
|
3275
|
+
default:
|
|
3276
|
+
return 'assistant-file-upload-refused';
|
|
3277
|
+
}
|
|
3278
|
+
}
|
|
3279
|
+
function startsWith(head, signature) {
|
|
3280
|
+
return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
|
|
3281
|
+
}
|
|
3282
|
+
function indexOf(head, signature) {
|
|
3283
|
+
for (let start = 0; start + signature.length <= head.length; start++) {
|
|
3284
|
+
if (signature.every((byte, index) => head[start + index] === byte)) {
|
|
3285
|
+
return start;
|
|
3286
|
+
}
|
|
3287
|
+
}
|
|
3288
|
+
return -1;
|
|
3289
|
+
}
|
|
3290
|
+
|
|
2862
3291
|
const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2863
3292
|
const minQueryLength = 2;
|
|
2864
3293
|
const maxQueryLength = 100;
|
|
@@ -3246,7 +3675,7 @@ class NhAssistantMockAgentRuns {
|
|
|
3246
3675
|
result: this.answerOf(found.run.id, language),
|
|
3247
3676
|
resultTruncated: this.truncated.has(found.run.id),
|
|
3248
3677
|
summary: found.run.summary,
|
|
3249
|
-
proposals: [],
|
|
3678
|
+
proposals: this.context.runProposals?.(found.run.id) ?? [],
|
|
3250
3679
|
hiddenProposals: 0,
|
|
3251
3680
|
artifacts: [],
|
|
3252
3681
|
canOpenConversation: this.mayView(found.thread, language)
|
|
@@ -3829,9 +4258,9 @@ class NhAssistantMockAgentRuns {
|
|
|
3829
4258
|
const picked = localized ? mockPickText({ ...localized }, '', language) : null;
|
|
3830
4259
|
return picked ?? this.results.get(runId) ?? null;
|
|
3831
4260
|
}
|
|
3832
|
-
/** Neither stopped nor disabled in code; a paused agent still works for people. */
|
|
4261
|
+
/** Neither stopped nor disabled in code; a paused agent, and one that is off, still works for people. */
|
|
3833
4262
|
isWorking(agent) {
|
|
3834
|
-
return agent.runState === 'active' || agent.runState === 'paused';
|
|
4263
|
+
return agent.runState === 'active' || agent.runState === 'paused' || agent.runState === 'off';
|
|
3835
4264
|
}
|
|
3836
4265
|
mayUse(agent) {
|
|
3837
4266
|
return agent.requiredPolicy === null || agent.requiredPolicy === undefined || this.passes(agent.requiredPolicy);
|
|
@@ -3981,7 +4410,7 @@ const maxGroupId = 64;
|
|
|
3981
4410
|
const maxGroups = 100;
|
|
3982
4411
|
const maxName = 100;
|
|
3983
4412
|
const maxDescription = 300;
|
|
3984
|
-
const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
4413
|
+
const guid$1 = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
3985
4414
|
/**
|
|
3986
4415
|
* Groups and boards of the mock API (K15, K16), as the server serves them: the scenario's
|
|
3987
4416
|
* `agentGroups` are the groups in code with their names per language, checked as the server checks
|
|
@@ -4108,7 +4537,7 @@ class NhAssistantMockBoards {
|
|
|
4108
4537
|
}
|
|
4109
4538
|
const groupId = segments[1];
|
|
4110
4539
|
const divisionId = query.get('divisionId');
|
|
4111
|
-
if (divisionId !== null && divisionId !== '' && !guid.test(divisionId)) {
|
|
4540
|
+
if (divisionId !== null && divisionId !== '' && !guid$1.test(divisionId)) {
|
|
4112
4541
|
return mockFailure('assistant-board-scope-invalid');
|
|
4113
4542
|
}
|
|
4114
4543
|
const division = divisionId ? this.division(divisionId, requested) : null;
|
|
@@ -4249,6 +4678,7 @@ const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
4249
4678
|
const maxReason = 500;
|
|
4250
4679
|
const maxItemsPerPage = 100;
|
|
4251
4680
|
const maxBulkDecisions = 50;
|
|
4681
|
+
const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
4252
4682
|
const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
|
|
4253
4683
|
/** The platform's subject type of the whole application. */
|
|
4254
4684
|
const systemType = 'nh-system';
|
|
@@ -4264,12 +4694,15 @@ const systemType = 'nh-system';
|
|
|
4264
4694
|
* answers `404` and deciding it `404 assistant-approval-not-found`.
|
|
4265
4695
|
*/
|
|
4266
4696
|
class NhAssistantMockProposals {
|
|
4267
|
-
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined) {
|
|
4697
|
+
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
|
|
4268
4698
|
this.actorId = actorId;
|
|
4269
4699
|
this.now = now;
|
|
4270
4700
|
this.mayDecideApplication = mayDecideApplication;
|
|
4271
4701
|
this.decided = decided;
|
|
4702
|
+
this.mayWorkIn = mayWorkIn;
|
|
4272
4703
|
this.proposals = new Map();
|
|
4704
|
+
/** When the caller last looked at the inbox, by scope key. */
|
|
4705
|
+
this.visits = new Map();
|
|
4273
4706
|
const created = new Date(now()).getTime();
|
|
4274
4707
|
for (const item of scenario) {
|
|
4275
4708
|
if (this.proposals.has(item.approvalId)) {
|
|
@@ -4300,8 +4733,8 @@ class NhAssistantMockProposals {
|
|
|
4300
4733
|
mode: 'deferred',
|
|
4301
4734
|
policyId: null,
|
|
4302
4735
|
grantSource: 'agent',
|
|
4303
|
-
createdAt: new Date(created).toISOString(),
|
|
4304
|
-
expiresAt: new Date(created + lifetimeMs).toISOString(),
|
|
4736
|
+
createdAt: item.createdAt ?? new Date(created).toISOString(),
|
|
4737
|
+
expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
|
|
4305
4738
|
initiatorActorId: null,
|
|
4306
4739
|
decidedByActorId: null,
|
|
4307
4740
|
decidedAt: null,
|
|
@@ -4309,8 +4742,10 @@ class NhAssistantMockProposals {
|
|
|
4309
4742
|
executedAt: null,
|
|
4310
4743
|
rationale: item.rationale ?? null,
|
|
4311
4744
|
placedBy: item.placedBy ?? null,
|
|
4745
|
+
parent: item.parent ?? null,
|
|
4312
4746
|
canGiveFeedback: false
|
|
4313
4747
|
},
|
|
4748
|
+
divisionId: item.divisionId ?? null,
|
|
4314
4749
|
canDecide: item.canDecide ?? true,
|
|
4315
4750
|
reasonCode: item.canDecide === false ? item.reasonCode ?? 'assistant-approval-policy-denied' : null
|
|
4316
4751
|
});
|
|
@@ -4321,6 +4756,9 @@ class NhAssistantMockProposals {
|
|
|
4321
4756
|
if (segments.length === 2 && segments[1] === 'inbox') {
|
|
4322
4757
|
return method === 'GET' ? this.inbox(query) : { status: 405 };
|
|
4323
4758
|
}
|
|
4759
|
+
if (segments.length === 3 && segments[1] === 'inbox' && segments[2] === 'visit') {
|
|
4760
|
+
return method === 'POST' ? this.visit(query) : { status: 405 };
|
|
4761
|
+
}
|
|
4324
4762
|
if (segments.length === 2 && segments[1] === 'lookup') {
|
|
4325
4763
|
return method === 'POST' ? this.lookup(body) : { status: 405 };
|
|
4326
4764
|
}
|
|
@@ -4343,6 +4781,12 @@ class NhAssistantMockProposals {
|
|
|
4343
4781
|
.reverse();
|
|
4344
4782
|
return items.length === 0 ? null : items;
|
|
4345
4783
|
}
|
|
4784
|
+
/** The proposals of a run the caller may see, in the order the scenario lists them. */
|
|
4785
|
+
ofRun(runId) {
|
|
4786
|
+
return this.visible()
|
|
4787
|
+
.filter(item => item.proposal.runId === runId)
|
|
4788
|
+
.map(item => item.proposal);
|
|
4789
|
+
}
|
|
4346
4790
|
/** The pending proposals on a subject with the caller's right to decide each, earliest expiry first. */
|
|
4347
4791
|
openOfSubject(subjectType, subjectId) {
|
|
4348
4792
|
return this.visible()
|
|
@@ -4372,19 +4816,49 @@ class NhAssistantMockProposals {
|
|
|
4372
4816
|
}
|
|
4373
4817
|
const agentId = query.get('agentId');
|
|
4374
4818
|
const runId = query.get('runId');
|
|
4819
|
+
const divisionId = query.get('divisionId');
|
|
4820
|
+
if (divisionId !== null && !guid.test(divisionId)) {
|
|
4821
|
+
return { status: 400 };
|
|
4822
|
+
}
|
|
4823
|
+
const grouped = query.get('grouped') === 'true';
|
|
4375
4824
|
const items = this.visible()
|
|
4376
4825
|
.filter(item => item.proposal.status === 'pending' && item.canDecide)
|
|
4826
|
+
.filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
|
|
4377
4827
|
.map(item => item.proposal)
|
|
4378
4828
|
.filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
|
|
4379
4829
|
&& (agentId === null || proposal.agentId === agentId)
|
|
4380
4830
|
&& (runId === null || proposal.runId === runId))
|
|
4381
4831
|
.sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
|
|
4832
|
+
const ordered = grouped ? groupedOrder(items) : items;
|
|
4833
|
+
const offset = (page - 1) * itemsPerPage;
|
|
4834
|
+
const pageItems = ordered.slice(offset, offset + itemsPerPage);
|
|
4835
|
+
const scopeKey = scopeKeyOf(divisionId);
|
|
4836
|
+
const lastVisitedAt = this.visits.get(scopeKey) ?? null;
|
|
4382
4837
|
const body = {
|
|
4383
|
-
items:
|
|
4384
|
-
total: items.length
|
|
4838
|
+
items: pageItems,
|
|
4839
|
+
total: items.length,
|
|
4840
|
+
scopeKey,
|
|
4841
|
+
lastVisitedAt,
|
|
4842
|
+
newApprovalIds: pageItems
|
|
4843
|
+
.filter(proposal => lastVisitedAt === null || Date.parse(proposal.createdAt) > Date.parse(lastVisitedAt))
|
|
4844
|
+
.map(proposal => proposal.approvalId),
|
|
4845
|
+
groups: grouped ? groupsOf(ordered, offset, pageItems.length) : null
|
|
4385
4846
|
};
|
|
4386
4847
|
return { status: 200, body };
|
|
4387
4848
|
}
|
|
4849
|
+
/** `POST approvals/inbox/visit`: the whole inbox, or that of a division the caller works in. */
|
|
4850
|
+
visit(query) {
|
|
4851
|
+
const divisionId = query.get('divisionId');
|
|
4852
|
+
if (divisionId !== null && !guid.test(divisionId)) {
|
|
4853
|
+
return { status: 400 };
|
|
4854
|
+
}
|
|
4855
|
+
if (divisionId !== null && !this.mayWorkIn(divisionId)) {
|
|
4856
|
+
return mockFailure('assistant-subject-not-found');
|
|
4857
|
+
}
|
|
4858
|
+
const body = { scopeKey: scopeKeyOf(divisionId), visitedAt: this.now() };
|
|
4859
|
+
this.visits.set(body.scopeKey, body.visitedAt);
|
|
4860
|
+
return { status: 200, body };
|
|
4861
|
+
}
|
|
4388
4862
|
read(approvalId) {
|
|
4389
4863
|
const stored = this.find(approvalId);
|
|
4390
4864
|
return stored ? { status: 200, body: this.withRight(stored) } : mockFailure('assistant-approval-not-found');
|
|
@@ -4472,6 +4946,99 @@ class NhAssistantMockProposals {
|
|
|
4472
4946
|
};
|
|
4473
4947
|
}
|
|
4474
4948
|
}
|
|
4949
|
+
/** The scope of the inbox's visits: `global`, or the scope key of a division. */
|
|
4950
|
+
function scopeKeyOf(divisionId) {
|
|
4951
|
+
return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
|
|
4952
|
+
}
|
|
4953
|
+
function sameGuid(left, right) {
|
|
4954
|
+
return left !== null && left.replaceAll('-', '').toLowerCase() === right.replaceAll('-', '').toLowerCase();
|
|
4955
|
+
}
|
|
4956
|
+
/** The group a proposal belongs to: its parent, or its own subject without one. */
|
|
4957
|
+
function groupKeyOf(proposal) {
|
|
4958
|
+
return proposal.parent
|
|
4959
|
+
? `${proposal.parent.subjectType}|${proposal.parent.subjectId}`
|
|
4960
|
+
: `${proposal.subjectType}|${proposal.subjectId}`;
|
|
4961
|
+
}
|
|
4962
|
+
function subjectKeyOf(proposal) {
|
|
4963
|
+
return `${groupKeyOf(proposal)}|${proposal.subjectType}|${proposal.subjectId}`;
|
|
4964
|
+
}
|
|
4965
|
+
function firstExpiries(items, key) {
|
|
4966
|
+
const first = new Map();
|
|
4967
|
+
for (const proposal of items) {
|
|
4968
|
+
const current = first.get(key(proposal));
|
|
4969
|
+
if (current === undefined || proposal.expiresAt < current) {
|
|
4970
|
+
first.set(key(proposal), proposal.expiresAt);
|
|
4971
|
+
}
|
|
4972
|
+
}
|
|
4973
|
+
return first;
|
|
4974
|
+
}
|
|
4975
|
+
/**
|
|
4976
|
+
* The order of the grouped inbox, as the server sorts: groups by their first expiry, a group's own
|
|
4977
|
+
* subject first, then its other subjects by their first expiry, one run's proposals together, then by
|
|
4978
|
+
* expiry.
|
|
4979
|
+
*/
|
|
4980
|
+
function groupedOrder(items) {
|
|
4981
|
+
const groups = firstExpiries(items, groupKeyOf);
|
|
4982
|
+
const subjects = firstExpiries(items, subjectKeyOf);
|
|
4983
|
+
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
4984
|
+
return [...items].sort((left, right) => compare(groups.get(groupKeyOf(left)), groups.get(groupKeyOf(right)))
|
|
4985
|
+
|| compare(groupKeyOf(left), groupKeyOf(right))
|
|
4986
|
+
|| (left.parent ? 1 : 0) - (right.parent ? 1 : 0)
|
|
4987
|
+
|| compare(subjects.get(subjectKeyOf(left)), subjects.get(subjectKeyOf(right)))
|
|
4988
|
+
|| compare(subjectKeyOf(left), subjectKeyOf(right))
|
|
4989
|
+
|| (left.runId === null ? 1 : 0) - (right.runId === null ? 1 : 0)
|
|
4990
|
+
|| compare(left.runId ?? '', right.runId ?? '')
|
|
4991
|
+
|| compare(left.expiresAt, right.expiresAt)
|
|
4992
|
+
|| compare(left.approvalId, right.approvalId));
|
|
4993
|
+
}
|
|
4994
|
+
/** The groups of the page `ordered[offset, offset + count)`, with their totals and continuation. */
|
|
4995
|
+
function groupsOf(ordered, offset, count) {
|
|
4996
|
+
const page = ordered.slice(offset, offset + count);
|
|
4997
|
+
const previous = offset > 0 ? ordered[offset - 1] : undefined;
|
|
4998
|
+
const next = ordered[offset + count];
|
|
4999
|
+
const groups = [];
|
|
5000
|
+
for (let index = 0; index < page.length;) {
|
|
5001
|
+
const key = groupKeyOf(page[index]);
|
|
5002
|
+
let end = index;
|
|
5003
|
+
while (end < page.length && groupKeyOf(page[end]) === key) {
|
|
5004
|
+
end++;
|
|
5005
|
+
}
|
|
5006
|
+
const members = page.slice(index, end);
|
|
5007
|
+
const own = members.find(proposal => !proposal.parent);
|
|
5008
|
+
const parent = members.find(proposal => proposal.parent)?.parent ?? null;
|
|
5009
|
+
const subjects = [];
|
|
5010
|
+
for (let at = 0; at < members.length;) {
|
|
5011
|
+
const subjectKey = subjectKeyOf(members[at]);
|
|
5012
|
+
let stop = at;
|
|
5013
|
+
while (stop < members.length && subjectKeyOf(members[stop]) === subjectKey) {
|
|
5014
|
+
stop++;
|
|
5015
|
+
}
|
|
5016
|
+
subjects.push({
|
|
5017
|
+
subjectType: members[at].subjectType,
|
|
5018
|
+
subjectId: members[at].subjectId,
|
|
5019
|
+
count: stop - at,
|
|
5020
|
+
total: ordered.filter(proposal => subjectKeyOf(proposal) === subjectKey).length,
|
|
5021
|
+
continuesFromPreviousPage: index === 0 && at === 0 && previous !== undefined && subjectKeyOf(previous) === subjectKey,
|
|
5022
|
+
continuesOnNextPage: end === page.length && stop === members.length && next !== undefined && subjectKeyOf(next) === subjectKey
|
|
5023
|
+
});
|
|
5024
|
+
at = stop;
|
|
5025
|
+
}
|
|
5026
|
+
groups.push({
|
|
5027
|
+
subjectType: own?.subjectType ?? parent.subjectType,
|
|
5028
|
+
subjectId: own?.subjectId ?? parent.subjectId,
|
|
5029
|
+
displayName: own ? own.subjectDisplayName : parent.displayName,
|
|
5030
|
+
subjectTypeName: own ? own.subjectTypeName ?? null : parent.subjectTypeName ?? null,
|
|
5031
|
+
subjectLink: own ? own.subjectLink ?? null : parent.subjectLink ?? null,
|
|
5032
|
+
count: members.length,
|
|
5033
|
+
total: ordered.filter(proposal => groupKeyOf(proposal) === key).length,
|
|
5034
|
+
continuesFromPreviousPage: index === 0 && previous !== undefined && groupKeyOf(previous) === key,
|
|
5035
|
+
continuesOnNextPage: end === page.length && next !== undefined && groupKeyOf(next) === key,
|
|
5036
|
+
subjects
|
|
5037
|
+
});
|
|
5038
|
+
index = end;
|
|
5039
|
+
}
|
|
5040
|
+
return groups;
|
|
5041
|
+
}
|
|
4475
5042
|
function validDecision(request) {
|
|
4476
5043
|
return !!request
|
|
4477
5044
|
&& (request.decision === 'approve' || request.decision === 'reject')
|
|
@@ -4539,12 +5106,13 @@ class NhAssistantMockBackend {
|
|
|
4539
5106
|
runId: proposal.runId,
|
|
4540
5107
|
subjectType: proposal.subjectType,
|
|
4541
5108
|
subjectId: proposal.subjectId
|
|
4542
|
-
}, proposal.agentId));
|
|
5109
|
+
}, proposal.agentId), (divisionId) => this.subjects.describe(mockSystemSubjectType, divisionSubjectId({ id: divisionId, name: '' }), null, this.admin.administers) != null);
|
|
4543
5110
|
this.subjects = new NhAssistantMockSubjects(this.scenario.subjectTypes, this.scenario.divisions, (type, id) => this.proposals.openOfSubject(type, id));
|
|
4544
5111
|
this.agentRuns = new NhAssistantMockAgentRuns(this.scenario, this.subjects, {
|
|
4545
5112
|
agents: () => this.admin.allAgents(),
|
|
4546
5113
|
administers: () => this.admin.administers,
|
|
4547
5114
|
threadProposals: conversationId => this.proposals.ofThread(conversationId),
|
|
5115
|
+
runProposals: runId => this.proposals.ofRun(runId),
|
|
4548
5116
|
workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
|
|
4549
5117
|
}, () => new Date().toISOString());
|
|
4550
5118
|
this.boards = new NhAssistantMockBoards(this.scenario, this.subjects, this.agentRuns, {
|
|
@@ -4557,6 +5125,7 @@ class NhAssistantMockBackend {
|
|
|
4557
5125
|
this.shareTokens = new Map();
|
|
4558
5126
|
this.activeActors = new Map();
|
|
4559
5127
|
this.liveSubject = new Subject();
|
|
5128
|
+
this.files = new NhAssistantMockFiles(this.scenario.refuseUpload, this.scenario.scanUpload);
|
|
4560
5129
|
this.pushEnabled = true;
|
|
4561
5130
|
this.sequence = 0;
|
|
4562
5131
|
/** Requests received so far, oldest first. */
|
|
@@ -4570,12 +5139,45 @@ class NhAssistantMockBackend {
|
|
|
4570
5139
|
* provides it as `NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW`.
|
|
4571
5140
|
*/
|
|
4572
5141
|
this.authorizationWindow = new NhAssistantMockAuthorizationWindow(flowId => this.admin.authorizeFlow(flowId));
|
|
5142
|
+
/**
|
|
5143
|
+
* An upload implementation (`NH_ASSISTANT_UPLOAD`) that answers `POST conversations/{id}/files` with
|
|
5144
|
+
* the server's checks, after reporting the progress in four steps.
|
|
5145
|
+
*/
|
|
5146
|
+
this.upload = async (request) => {
|
|
5147
|
+
const path = this.pathOf(request.url);
|
|
5148
|
+
const file = request.body.get('file');
|
|
5149
|
+
this.requests.push({
|
|
5150
|
+
method: 'POST',
|
|
5151
|
+
path: path ?? request.url,
|
|
5152
|
+
headers: { ...request.headers },
|
|
5153
|
+
body: file instanceof Blob ? { name: file instanceof File ? file.name : null, mediaType: file.type, size: file.size } : undefined
|
|
5154
|
+
});
|
|
5155
|
+
const total = file instanceof Blob ? file.size : 0;
|
|
5156
|
+
for (const share of [0.25, 0.5, 0.75, 1]) {
|
|
5157
|
+
if (request.signal.aborted) {
|
|
5158
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
5159
|
+
}
|
|
5160
|
+
await delay(this.scenario.timing?.eventDelayMs ?? 40);
|
|
5161
|
+
request.onProgress(Math.round(total * share), total);
|
|
5162
|
+
}
|
|
5163
|
+
if (request.signal.aborted) {
|
|
5164
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
5165
|
+
}
|
|
5166
|
+
const segments = path?.split('/').map(segment => decodeURIComponent(segment)) ?? [];
|
|
5167
|
+
if (!this.enabledState() || segments.length !== 3 || segments[0] !== 'conversations' || segments[2] !== 'files') {
|
|
5168
|
+
return this.empty(404);
|
|
5169
|
+
}
|
|
5170
|
+
const conversation = this.conversations.get(segments[1]);
|
|
5171
|
+
if (!conversation) {
|
|
5172
|
+
return this.error('assistant-conversation-not-found');
|
|
5173
|
+
}
|
|
5174
|
+
return this.result(await this.files.upload(conversation.id, this.offersFiles(), this.uploadsOf(conversation.agentId), request.headers, request.body));
|
|
5175
|
+
};
|
|
4573
5176
|
/** A `fetch` implementation that answers the assistant endpoints below `apiBaseUrl`. */
|
|
4574
5177
|
this.fetch = async (input, init) => {
|
|
4575
5178
|
const method = (init.method ?? 'GET').toUpperCase();
|
|
4576
5179
|
const url = new URL(input, 'http://mock.local');
|
|
4577
|
-
const
|
|
4578
|
-
const path = url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
|
|
5180
|
+
const path = this.pathOf(input);
|
|
4579
5181
|
const body = typeof init.body === 'string' && init.body.length > 0 ? JSON.parse(init.body) : undefined;
|
|
4580
5182
|
this.requests.push({ method, path: path ?? url.pathname, headers: { ...init.headers }, body });
|
|
4581
5183
|
if (init.signal?.aborted) {
|
|
@@ -4654,6 +5256,10 @@ class NhAssistantMockBackend {
|
|
|
4654
5256
|
setMcpConnectionStatus(serverId, status) {
|
|
4655
5257
|
this.admin.setMcpConnectionStatus(serverId, status);
|
|
4656
5258
|
}
|
|
5259
|
+
/** The files people uploaded, oldest first, as the application's file source stored them. */
|
|
5260
|
+
get uploads() {
|
|
5261
|
+
return this.files.uploads;
|
|
5262
|
+
}
|
|
4657
5263
|
route(method, path, query, body, language = null) {
|
|
4658
5264
|
const segments = path.split('/').map(segment => decodeURIComponent(segment));
|
|
4659
5265
|
if (method === 'GET' && path === 'status') {
|
|
@@ -4831,6 +5437,12 @@ class NhAssistantMockBackend {
|
|
|
4831
5437
|
if (text.length === 0 || text.length > limit || !request.clientMessageId) {
|
|
4832
5438
|
return this.empty(400);
|
|
4833
5439
|
}
|
|
5440
|
+
// Like the server: the attachments are checked before the turn claims the conversation.
|
|
5441
|
+
const uploads = this.uploadsOf(conversation.agentId);
|
|
5442
|
+
const filesCode = this.files.checkMessage(request.files, this.offersFiles(), uploads?.maxFiles ?? 5);
|
|
5443
|
+
if (filesCode) {
|
|
5444
|
+
return this.error(filesCode);
|
|
5445
|
+
}
|
|
4834
5446
|
if (!acceptsMessage(conversation.status)) {
|
|
4835
5447
|
return this.empty(409);
|
|
4836
5448
|
}
|
|
@@ -4840,6 +5452,14 @@ class NhAssistantMockBackend {
|
|
|
4840
5452
|
const turn = this.findTurn(text, conversation.agentId, pageContext);
|
|
4841
5453
|
const userMessageId = this.nextId();
|
|
4842
5454
|
const assistantMessageId = this.nextId();
|
|
5455
|
+
const files = request.files ?? [];
|
|
5456
|
+
// The turn opens the attachments with the sender's rights; it ends with the first refusal and the
|
|
5457
|
+
// message keeps only what the model read.
|
|
5458
|
+
const refusal = files.length === 0 ? null : uploads ? this.files.openCode(conversation.id, files) : 'assistant-files-not-accepted';
|
|
5459
|
+
const parts = [
|
|
5460
|
+
{ type: 'text', text },
|
|
5461
|
+
...(refusal ? [] : files.map(file => ({ type: 'file', name: file.name, mediaType: file.mediaType, size: file.size })))
|
|
5462
|
+
];
|
|
4843
5463
|
this.activeActors.set(conversation.id, this.currentUser.actorId);
|
|
4844
5464
|
this.update(conversation.id, current => ({
|
|
4845
5465
|
...current,
|
|
@@ -4847,14 +5467,17 @@ class NhAssistantMockBackend {
|
|
|
4847
5467
|
status: 'running',
|
|
4848
5468
|
messages: [
|
|
4849
5469
|
...current.messages,
|
|
4850
|
-
{ id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts
|
|
5470
|
+
{ id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts, authorActorId: this.currentUser.actorId }
|
|
4851
5471
|
]
|
|
4852
5472
|
}));
|
|
4853
5473
|
// The sender has read their own message.
|
|
4854
5474
|
this.readPositions.set(conversation.id, lastSequence(this.conversations.get(conversation.id)));
|
|
4855
5475
|
return this.stream(conversation.id, async (emit) => {
|
|
4856
5476
|
await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId, assistantMessageId } });
|
|
4857
|
-
|
|
5477
|
+
const steps = refusal
|
|
5478
|
+
? [{ error: { code: refusal, messageKey: `nh-assistant.errors.${refusal}` } }]
|
|
5479
|
+
: turn?.steps ?? [];
|
|
5480
|
+
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
4858
5481
|
});
|
|
4859
5482
|
}
|
|
4860
5483
|
decide(conversation, approvalId, request) {
|
|
@@ -5218,6 +5841,22 @@ class NhAssistantMockBackend {
|
|
|
5218
5841
|
error(code) {
|
|
5219
5842
|
return this.result(mockFailure(code));
|
|
5220
5843
|
}
|
|
5844
|
+
/** The path below `apiBaseUrl`, or `null` for another URL. */
|
|
5845
|
+
pathOf(input) {
|
|
5846
|
+
const url = new URL(input, 'http://mock.local');
|
|
5847
|
+
const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
|
|
5848
|
+
return url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
|
|
5849
|
+
}
|
|
5850
|
+
/** Whether the application keeps files for the assistant: an agent of the scenario takes uploads. */
|
|
5851
|
+
offersFiles() {
|
|
5852
|
+
return this.scenario.agents.some(agent => !!agent.uploads);
|
|
5853
|
+
}
|
|
5854
|
+
/** What people may attach to a message to the agent, as its summary says. */
|
|
5855
|
+
uploadsOf(agentId) {
|
|
5856
|
+
return this.admin.chatAgents().find(agent => agent.id === agentId)?.uploads
|
|
5857
|
+
?? this.scenario.agents.find(agent => agent.id === agentId)?.uploads
|
|
5858
|
+
?? null;
|
|
5859
|
+
}
|
|
5221
5860
|
/**
|
|
5222
5861
|
* Whether the caller decides the agent's proposals of the whole application: an approver of the whole
|
|
5223
5862
|
* application (an administrator in the mock) who passes the agent's view policy, by default its
|
|
@@ -5381,15 +6020,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImpo
|
|
|
5381
6020
|
* Replaces the assistant transport with an in-memory back-end that plays `script`.
|
|
5382
6021
|
* Register it after `provideNhAssistant(...)` in the same injector. Every client feature
|
|
5383
6022
|
* (status, agents, conversations, streamed turns, approvals, cancellation, sharing, read
|
|
5384
|
-
* state, live updates and MCP authorizations) then works without a
|
|
5385
|
-
* `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or
|
|
5386
|
-
* received requests.
|
|
6023
|
+
* state, live updates, uploads with their progress and MCP authorizations) then works without a
|
|
6024
|
+
* server. Inject `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or
|
|
6025
|
+
* inspect the received requests and uploads.
|
|
5387
6026
|
*/
|
|
5388
6027
|
function provideNhAssistantMockApi(script) {
|
|
5389
6028
|
return makeEnvironmentProviders([
|
|
5390
6029
|
{ provide: NH_ASSISTANT_MOCK_SCENARIO, useValue: script },
|
|
5391
6030
|
NhAssistantMockBackend,
|
|
5392
6031
|
{ provide: NH_ASSISTANT_FETCH, useFactory: () => inject(NhAssistantMockBackend).fetch },
|
|
6032
|
+
{ provide: NH_ASSISTANT_UPLOAD, useFactory: () => inject(NhAssistantMockBackend).upload },
|
|
5393
6033
|
{ provide: NhAssistantLiveService, useClass: NhAssistantMockLiveService },
|
|
5394
6034
|
{ provide: NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW, useFactory: () => inject(NhAssistantMockBackend).authorizationWindow }
|
|
5395
6035
|
]);
|