@newheap/platform-ai-chat 0.15.0 → 0.16.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/admin/index.d.ts +106 -13
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +446 -36
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +522 -43
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1054 -63
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +127 -8
- package/i18n/nl.json +127 -8
- package/index.d.ts +424 -38
- package/package.json +1 -1
- package/testing/index.d.ts +64 -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
|
/**
|
|
@@ -39,7 +39,12 @@ const statusTable = {
|
|
|
39
39
|
'assistant-mcp-authorization-invalid',
|
|
40
40
|
'assistant-mcp-pkce-unsupported',
|
|
41
41
|
'assistant-mcp-client-registration-required',
|
|
42
|
-
'assistant-mcp-oauth-not-configured'
|
|
42
|
+
'assistant-mcp-oauth-not-configured',
|
|
43
|
+
'assistant-file-reference-invalid',
|
|
44
|
+
'assistant-file-upload-invalid',
|
|
45
|
+
'assistant-file-too-many-pages',
|
|
46
|
+
'assistant-file-count-limit',
|
|
47
|
+
'assistant-file-token-limit'
|
|
43
48
|
],
|
|
44
49
|
401: [
|
|
45
50
|
'assistant-webhook-signature-invalid'
|
|
@@ -55,7 +60,8 @@ const statusTable = {
|
|
|
55
60
|
'assistant-four-eyes-required',
|
|
56
61
|
'assistant-forbidden',
|
|
57
62
|
'assistant-mcp-credential-mismatch',
|
|
58
|
-
'assistant-mcp-initiator-only'
|
|
63
|
+
'assistant-mcp-initiator-only',
|
|
64
|
+
'assistant-file-forbidden'
|
|
59
65
|
],
|
|
60
66
|
404: [
|
|
61
67
|
'assistant-disabled',
|
|
@@ -79,7 +85,9 @@ const statusTable = {
|
|
|
79
85
|
'assistant-not-found',
|
|
80
86
|
'assistant-context-not-found',
|
|
81
87
|
'assistant-mcp-server-not-found',
|
|
82
|
-
'assistant-mcp-tool-not-found'
|
|
88
|
+
'assistant-mcp-tool-not-found',
|
|
89
|
+
'assistant-files-unavailable',
|
|
90
|
+
'assistant-file-not-found'
|
|
83
91
|
],
|
|
84
92
|
409: [
|
|
85
93
|
'assistant-conversation-busy',
|
|
@@ -123,10 +131,20 @@ const statusTable = {
|
|
|
123
131
|
'assistant-mcp-issuer-changed',
|
|
124
132
|
'assistant-mcp-authorization-not-confirmed',
|
|
125
133
|
'assistant-mcp-connection-not-supported',
|
|
126
|
-
'assistant-mcp-secret-unreadable'
|
|
134
|
+
'assistant-mcp-secret-unreadable',
|
|
135
|
+
'assistant-files-not-accepted'
|
|
127
136
|
],
|
|
128
137
|
413: [
|
|
129
|
-
'assistant-webhook-payload-too-large'
|
|
138
|
+
'assistant-webhook-payload-too-large',
|
|
139
|
+
'assistant-file-too-large'
|
|
140
|
+
],
|
|
141
|
+
415: [
|
|
142
|
+
'assistant-file-type-not-supported',
|
|
143
|
+
'assistant-file-type-not-accepted'
|
|
144
|
+
],
|
|
145
|
+
422: [
|
|
146
|
+
'assistant-file-upload-refused',
|
|
147
|
+
'assistant-file-scan-refused'
|
|
130
148
|
],
|
|
131
149
|
429: [
|
|
132
150
|
'assistant-mcp-too-many-authorizations'
|
|
@@ -142,7 +160,9 @@ const statusTable = {
|
|
|
142
160
|
503: [
|
|
143
161
|
'assistant-proposal-check-failed',
|
|
144
162
|
'assistant-trigger-rolled-back',
|
|
145
|
-
'assistant-mcp-secret-unavailable'
|
|
163
|
+
'assistant-mcp-secret-unavailable',
|
|
164
|
+
'assistant-file-unavailable',
|
|
165
|
+
'assistant-file-upload-failed'
|
|
146
166
|
]
|
|
147
167
|
};
|
|
148
168
|
const statuses = buildStatuses();
|
|
@@ -188,6 +208,16 @@ function mockValidationFailure(field) {
|
|
|
188
208
|
return mockFailure('assistant-validation', field ? { [field]: ['invalid'] } : undefined);
|
|
189
209
|
}
|
|
190
210
|
/** A stable, non-cryptographic fingerprint; the mock only needs change detection. */
|
|
211
|
+
/**
|
|
212
|
+
* The warnings of an agent or skill that classifies its model calls with `classification` on
|
|
213
|
+
* `profile`: `classification-not-permitted` when the scenario lists what the profile and its
|
|
214
|
+
* fallbacks permit and that does not include it, as the server reports it; none without a
|
|
215
|
+
* classification of its own.
|
|
216
|
+
*/
|
|
217
|
+
function mockClassificationWarnings(permitted, profile, classification) {
|
|
218
|
+
const allowed = profile ? permitted?.[profile] : undefined;
|
|
219
|
+
return classification && allowed && !allowed.includes(classification) ? ['classification-not-permitted'] : [];
|
|
220
|
+
}
|
|
191
221
|
function mockHashText(text) {
|
|
192
222
|
let hash = 0x811c9dc5;
|
|
193
223
|
for (let index = 0; index < text.length; index++) {
|
|
@@ -333,7 +363,10 @@ function mockWorksOnCovers(agent, subjectType) {
|
|
|
333
363
|
return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
|
|
334
364
|
}
|
|
335
365
|
|
|
366
|
+
/** The profile of an agent that did not choose one, as the application's chat profile. */
|
|
367
|
+
const mockChatProfile = 'chat';
|
|
336
368
|
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
369
|
+
const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
337
370
|
/**
|
|
338
371
|
* The agents of the mock administration: agents in code and agents created as data, their state,
|
|
339
372
|
* shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
|
|
@@ -367,7 +400,7 @@ class NhAssistantMockAdminAgents {
|
|
|
367
400
|
skills: (skills ?? []).map((skill) => ({
|
|
368
401
|
skillId: skill.skillId,
|
|
369
402
|
description: skill.description,
|
|
370
|
-
runState: 'active',
|
|
403
|
+
runState: skill.startsOff === true ? 'off' : 'active',
|
|
371
404
|
changedAt: null,
|
|
372
405
|
changedBy: null,
|
|
373
406
|
reason: null,
|
|
@@ -468,9 +501,15 @@ class NhAssistantMockAdminAgents {
|
|
|
468
501
|
if (typeof engaged !== 'boolean') {
|
|
469
502
|
return mockValidationFailure();
|
|
470
503
|
}
|
|
471
|
-
|
|
504
|
+
// Releasing never switches on an agent that was off, as on the server: one that was off when
|
|
505
|
+
// it stopped goes back off, and one that is off stays off.
|
|
506
|
+
const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
|
|
507
|
+
const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
|
|
472
508
|
return changed.status === 200
|
|
473
|
-
? {
|
|
509
|
+
? {
|
|
510
|
+
status: 200,
|
|
511
|
+
body: { agentId: id, enabled: stored.agent.runState !== 'stopped', cancelledRuns: 0, runState: stored.agent.runState }
|
|
512
|
+
}
|
|
474
513
|
: changed;
|
|
475
514
|
}
|
|
476
515
|
if (length !== 3) {
|
|
@@ -511,7 +550,7 @@ class NhAssistantMockAdminAgents {
|
|
|
511
550
|
return { status: 405 };
|
|
512
551
|
}
|
|
513
552
|
const request = body;
|
|
514
|
-
if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped') {
|
|
553
|
+
if (request?.state !== 'active' && request?.state !== 'paused' && request?.state !== 'stopped' && request?.state !== 'off') {
|
|
515
554
|
return mockValidationFailure();
|
|
516
555
|
}
|
|
517
556
|
const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
@@ -672,9 +711,9 @@ class NhAssistantMockAdminAgents {
|
|
|
672
711
|
}
|
|
673
712
|
/**
|
|
674
713
|
* A skill defined as data shows among the agent's skills as a skill in shadow by default, as the
|
|
675
|
-
* server shows it
|
|
714
|
+
* server shows it, and off when it was added with `startsOff`; removing it takes its state along.
|
|
676
715
|
*/
|
|
677
|
-
setDataSkill(agentId, skillId, description) {
|
|
716
|
+
setDataSkill(agentId, skillId, description, startsOff = false) {
|
|
678
717
|
const stored = this.agents.get(agentId);
|
|
679
718
|
if (!stored) {
|
|
680
719
|
return;
|
|
@@ -688,9 +727,9 @@ class NhAssistantMockAdminAgents {
|
|
|
688
727
|
: [...others, existing ? { ...existing, description } : {
|
|
689
728
|
skillId,
|
|
690
729
|
description,
|
|
691
|
-
runState: 'active',
|
|
692
|
-
changedAt: null,
|
|
693
|
-
changedBy: null,
|
|
730
|
+
runState: startsOff ? 'off' : 'active',
|
|
731
|
+
changedAt: startsOff ? this.now() : null,
|
|
732
|
+
changedBy: startsOff ? 'current-user' : null,
|
|
694
733
|
reason: null,
|
|
695
734
|
waitingInputs: 0,
|
|
696
735
|
toolCallsToday: 0,
|
|
@@ -766,7 +805,8 @@ class NhAssistantMockAdminAgents {
|
|
|
766
805
|
}
|
|
767
806
|
/**
|
|
768
807
|
* `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
|
|
769
|
-
* collects no waiting events
|
|
808
|
+
* collects no waiting events, a stop cancels no runs and switching an agent off or on starts
|
|
809
|
+
* nothing; the version never changes.
|
|
770
810
|
*/
|
|
771
811
|
agentStateEndpoint(stored, method, body) {
|
|
772
812
|
if (method === 'GET') {
|
|
@@ -779,7 +819,7 @@ class NhAssistantMockAdminAgents {
|
|
|
779
819
|
if (request?.state === undefined || request.state === null) {
|
|
780
820
|
return mockValidationFailure();
|
|
781
821
|
}
|
|
782
|
-
if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped') {
|
|
822
|
+
if (request.state !== 'active' && request.state !== 'paused' && request.state !== 'stopped' && request.state !== 'off') {
|
|
783
823
|
return mockValidationFailure();
|
|
784
824
|
}
|
|
785
825
|
const reason = typeof request.reason === 'string' && request.reason.trim().length > 0 ? request.reason.trim() : null;
|
|
@@ -792,6 +832,8 @@ class NhAssistantMockAdminAgents {
|
|
|
792
832
|
: { status: 200, body: toAgentState(stored.agent) };
|
|
793
833
|
}
|
|
794
834
|
if (stored.agent.runState !== request.state) {
|
|
835
|
+
// A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
|
|
836
|
+
stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
|
|
795
837
|
stored.agent = {
|
|
796
838
|
...stored.agent,
|
|
797
839
|
isEnabled: request.state !== 'stopped',
|
|
@@ -821,6 +863,19 @@ class NhAssistantMockAdminAgents {
|
|
|
821
863
|
return mockFailure('assistant-validation', errors);
|
|
822
864
|
}
|
|
823
865
|
}
|
|
866
|
+
// An agent created here uses a profile the run catalog offers, or keeps the one it has or the
|
|
867
|
+
// chat profile it gets by default; an agent in code takes its own.
|
|
868
|
+
const profile = typeof input?.profile === 'string' ? input.profile.trim() : '';
|
|
869
|
+
if (source === 'admin' && profile.length > 0 && profile !== mockChatProfile
|
|
870
|
+
&& profile !== this.agents.get(input.id)?.agent.profile
|
|
871
|
+
&& !this.rules.profileNames().includes(profile)) {
|
|
872
|
+
return mockFailure('assistant-validation', { profile: ['not-found'] });
|
|
873
|
+
}
|
|
874
|
+
// An agent created here names the classification of its model calls, as a data skill does.
|
|
875
|
+
const classification = chosenClassification(input.dataClassification);
|
|
876
|
+
if (source === 'admin' && classification !== null && !dataClassifications$1.includes(classification)) {
|
|
877
|
+
return mockFailure('assistant-validation', { dataClassification: ['invalid'] });
|
|
878
|
+
}
|
|
824
879
|
return null;
|
|
825
880
|
}
|
|
826
881
|
validAgent(input) {
|
|
@@ -841,18 +896,28 @@ class NhAssistantMockAdminAgents {
|
|
|
841
896
|
* `previous`, and a new agent starts active.
|
|
842
897
|
*/
|
|
843
898
|
toAgent(input, source, version, isOverridden, previous) {
|
|
844
|
-
|
|
845
|
-
|
|
899
|
+
// Only a new agent starts off, created that way here or with StartsOff() in code.
|
|
900
|
+
const { isEnabled, startsOff, profile, dataClassification, ...definition } = structuredClone(input);
|
|
901
|
+
const createdOff = previous === undefined && startsOff === true;
|
|
902
|
+
const chosenProfile = source === 'admin' && typeof profile === 'string' && profile.trim().length > 0 ? profile.trim() : null;
|
|
903
|
+
const classification = chosenClassification(dataClassification);
|
|
904
|
+
return this.withWarnings({
|
|
846
905
|
...definition,
|
|
906
|
+
// An agent created here keeps its profile when the input omits it; code decides for the others.
|
|
907
|
+
profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
|
|
908
|
+
// The same for its classification; an agent in code keeps the one of its scenario.
|
|
909
|
+
dataClassification: source === 'admin' || previous === undefined
|
|
910
|
+
? classification ?? previous?.dataClassification ?? null
|
|
911
|
+
: previous.dataClassification ?? null,
|
|
847
912
|
isEnabled: previous?.isEnabled ?? true,
|
|
848
913
|
version,
|
|
849
914
|
source,
|
|
850
915
|
isOverridden,
|
|
851
916
|
instructionsHash: mockHashText(input.instructions),
|
|
852
917
|
updatedAt: this.now(),
|
|
853
|
-
runState: previous?.runState ?? 'active',
|
|
854
|
-
stateChangedAt: previous?.stateChangedAt ?? null,
|
|
855
|
-
stateChangedBy: previous?.stateChangedBy ?? null,
|
|
918
|
+
runState: previous?.runState ?? (createdOff ? 'off' : 'active'),
|
|
919
|
+
stateChangedAt: previous?.stateChangedAt ?? (createdOff && source === 'admin' ? this.now() : null),
|
|
920
|
+
stateChangedBy: previous?.stateChangedBy ?? (createdOff && source === 'admin' ? 'current-user' : null),
|
|
856
921
|
stateReason: previous?.stateReason ?? null,
|
|
857
922
|
waitingInputs: previous?.waitingInputs ?? 0,
|
|
858
923
|
skills: previous?.skills ?? [],
|
|
@@ -868,9 +933,20 @@ class NhAssistantMockAdminAgents {
|
|
|
868
933
|
? pickWorksOn(previous)
|
|
869
934
|
: mockWorksOnFromInput(input) ?? (previous ? pickWorksOn(previous) : { ...mockUndeclaredWorksOn, worksOnDeclared: true })),
|
|
870
935
|
knownSubjectTypes: previous?.knownSubjectTypes ?? []
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
/** The agent with the warnings the server reports for it, from its profile and classification. */
|
|
939
|
+
withWarnings(agent) {
|
|
940
|
+
return {
|
|
941
|
+
...agent,
|
|
942
|
+
warnings: mockClassificationWarnings(this.scenario.admin?.profileClassifications, agent.profile, agent.dataClassification)
|
|
871
943
|
};
|
|
872
944
|
}
|
|
873
945
|
}
|
|
946
|
+
/** A classification name the input chose, or `null` when it chose none. */
|
|
947
|
+
function chosenClassification(value) {
|
|
948
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
949
|
+
}
|
|
874
950
|
function codeAgentFromSummary(agent) {
|
|
875
951
|
return {
|
|
876
952
|
id: agent.id,
|
|
@@ -949,6 +1025,17 @@ const maxUserName = 256;
|
|
|
949
1025
|
const maxPolicy = 256;
|
|
950
1026
|
const maxClientId = 512;
|
|
951
1027
|
const maxAccountLabel = 200;
|
|
1028
|
+
/**
|
|
1029
|
+
* The readers of a server: at most 16, each with at most 16 media types, a tool name of at most 128
|
|
1030
|
+
* characters and a priority from -1000 to 1000.
|
|
1031
|
+
*/
|
|
1032
|
+
const maxReaders = 16;
|
|
1033
|
+
const maxReaderMediaTypes = 16;
|
|
1034
|
+
const maxReaderToolName = 128;
|
|
1035
|
+
const readerPriority = { min: -1000, max: 1000 };
|
|
1036
|
+
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
1037
|
+
const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
1038
|
+
const controlCharacters = /[\u0000-\u001f\u007f]/;
|
|
952
1039
|
/**
|
|
953
1040
|
* The MCP servers of the mock administration and the caller's own connections: secrets are
|
|
954
1041
|
* write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
|
|
@@ -990,6 +1077,12 @@ class NhAssistantMockAdminMcp {
|
|
|
990
1077
|
remoteTools: remoteTools ?? [],
|
|
991
1078
|
tools: new Map()
|
|
992
1079
|
};
|
|
1080
|
+
const readers = readersAfter(input, []);
|
|
1081
|
+
if (readers.length > 0) {
|
|
1082
|
+
// Preconfigured readers were set up after a sync of the server's tools.
|
|
1083
|
+
this.sync(stored);
|
|
1084
|
+
stored.server.readers = readers;
|
|
1085
|
+
}
|
|
993
1086
|
if (oauthModes.includes(input.authMode)) {
|
|
994
1087
|
// A preconfigured OAuth server was confirmed by an administrator before.
|
|
995
1088
|
const found = this.discovery(input.url);
|
|
@@ -1240,7 +1333,8 @@ class NhAssistantMockAdminMcp {
|
|
|
1240
1333
|
...toServerFields(input, stored.server),
|
|
1241
1334
|
...oauthFields(input, stored.server, oauth.authorization),
|
|
1242
1335
|
headlessUse,
|
|
1243
|
-
transport: input.transport ?? stored.server.transport
|
|
1336
|
+
transport: input.transport ?? stored.server.transport,
|
|
1337
|
+
readers: readersAfter(input, stored.server.readers)
|
|
1244
1338
|
};
|
|
1245
1339
|
if (!oauthModes.includes(input.authMode)) {
|
|
1246
1340
|
stored.oauthSecret = null;
|
|
@@ -1444,6 +1538,7 @@ class NhAssistantMockAdminMcp {
|
|
|
1444
1538
|
checkServerFields(input, stored, this.scenario.admin?.policies, add, this.scenario.admin?.mcpAuthModes ?? []);
|
|
1445
1539
|
this.checkCredential(input, stored, add);
|
|
1446
1540
|
this.checkOAuth(input, stored, add);
|
|
1541
|
+
checkReaders(input, stored, add);
|
|
1447
1542
|
return errors;
|
|
1448
1543
|
}
|
|
1449
1544
|
/**
|
|
@@ -1933,9 +2028,97 @@ function newServerState(input) {
|
|
|
1933
2028
|
headlessUse: input.headlessUse ?? 'never',
|
|
1934
2029
|
lastConnectAt: null,
|
|
1935
2030
|
lastConnectCode: null,
|
|
1936
|
-
transport: input.transport ?? 'streamable-http'
|
|
2031
|
+
transport: input.transport ?? 'streamable-http',
|
|
2032
|
+
// A new server has no synced tools, so it gets its readers after a sync.
|
|
2033
|
+
readers: []
|
|
1937
2034
|
};
|
|
1938
2035
|
}
|
|
2036
|
+
/** A media type in lowercase without parameters, or null for a value that is not one. */
|
|
2037
|
+
function normalizeMediaType$1(value) {
|
|
2038
|
+
if (typeof value !== 'string') {
|
|
2039
|
+
return null;
|
|
2040
|
+
}
|
|
2041
|
+
const essence = value.split(';')[0].trim().toLowerCase();
|
|
2042
|
+
return essence.length <= 128 && mediaTypePattern$1.test(essence) ? essence : null;
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* The readers after a valid input, as on the server: omitted or null readers keep the stored ones,
|
|
2046
|
+
* an empty list removes them all, and the media types are stored normalized and once each. The
|
|
2047
|
+
* server returns them by priority, highest first, then by tool name.
|
|
2048
|
+
*/
|
|
2049
|
+
function readersAfter(input, stored) {
|
|
2050
|
+
const readers = input.readers;
|
|
2051
|
+
if (readers === undefined || readers === null) {
|
|
2052
|
+
return stored;
|
|
2053
|
+
}
|
|
2054
|
+
return readers
|
|
2055
|
+
.map(reader => ({
|
|
2056
|
+
mediaTypes: [...new Set(reader.mediaTypes.map(normalizeMediaType$1).filter((mediaType) => mediaType !== null))],
|
|
2057
|
+
toolName: reader.toolName ?? '',
|
|
2058
|
+
maxClassification: reader.maxClassification ?? 'restricted',
|
|
2059
|
+
priority: reader.priority ?? 0
|
|
2060
|
+
}))
|
|
2061
|
+
.sort((left, right) => right.priority - left.priority || compareOrdinal(left.toolName, right.toolName));
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* The field errors of the readers, as the server reports them: `readers` (too-long for more than
|
|
2065
|
+
* 16) and per reader, with its index: `readers[0].mediaTypes` (required, invalid, too-long for more
|
|
2066
|
+
* than 16), `readers[0].toolName` (required, invalid also for a second reader of the same tool,
|
|
2067
|
+
* not-found when the server has not synced that tool), `readers[0].maxClassification` (required,
|
|
2068
|
+
* invalid) and `readers[0].priority` (invalid outside -1000 to 1000).
|
|
2069
|
+
*/
|
|
2070
|
+
function checkReaders(input, stored, add) {
|
|
2071
|
+
const readers = input.readers;
|
|
2072
|
+
if (!Array.isArray(readers) || readers.length === 0) {
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
2075
|
+
if (readers.length > maxReaders) {
|
|
2076
|
+
add('readers', 'too-long');
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const readTools = new Set();
|
|
2080
|
+
readers.forEach((reader, index) => {
|
|
2081
|
+
const field = (name) => `readers[${index}].${name}`;
|
|
2082
|
+
const mediaTypes = Array.isArray(reader?.mediaTypes) ? reader.mediaTypes.map(normalizeMediaType$1) : [];
|
|
2083
|
+
if (mediaTypes.length === 0) {
|
|
2084
|
+
add(field('mediaTypes'), 'required');
|
|
2085
|
+
}
|
|
2086
|
+
else if (mediaTypes.some(mediaType => mediaType === null)) {
|
|
2087
|
+
add(field('mediaTypes'), 'invalid');
|
|
2088
|
+
}
|
|
2089
|
+
else if (new Set(mediaTypes).size > maxReaderMediaTypes) {
|
|
2090
|
+
add(field('mediaTypes'), 'too-long');
|
|
2091
|
+
}
|
|
2092
|
+
const toolName = reader?.toolName;
|
|
2093
|
+
if (typeof toolName !== 'string' || toolName.trim().length === 0) {
|
|
2094
|
+
add(field('toolName'), 'required');
|
|
2095
|
+
}
|
|
2096
|
+
else if (toolName.length > maxReaderToolName || controlCharacters.test(toolName) || readTools.has(toolName)) {
|
|
2097
|
+
add(field('toolName'), 'invalid');
|
|
2098
|
+
}
|
|
2099
|
+
else if (!stored?.tools.has(toolName)) {
|
|
2100
|
+
add(field('toolName'), 'not-found');
|
|
2101
|
+
}
|
|
2102
|
+
if (typeof toolName === 'string') {
|
|
2103
|
+
readTools.add(toolName);
|
|
2104
|
+
}
|
|
2105
|
+
const classification = reader?.maxClassification;
|
|
2106
|
+
if (classification === null || classification === undefined) {
|
|
2107
|
+
add(field('maxClassification'), 'required');
|
|
2108
|
+
}
|
|
2109
|
+
else if (!classifications.includes(classification)) {
|
|
2110
|
+
add(field('maxClassification'), 'invalid');
|
|
2111
|
+
}
|
|
2112
|
+
const priority = reader?.priority ?? 0;
|
|
2113
|
+
if (!Number.isInteger(priority) || priority < readerPriority.min || priority > readerPriority.max) {
|
|
2114
|
+
add(field('priority'), 'invalid');
|
|
2115
|
+
}
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
/** Compares two names by their characters, as the server orders them. */
|
|
2119
|
+
function compareOrdinal(left, right) {
|
|
2120
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
2121
|
+
}
|
|
1939
2122
|
/**
|
|
1940
2123
|
* `organization` needs a credential of the organization; `personal` is the forwarded token of the
|
|
1941
2124
|
* person a run acts for.
|
|
@@ -2032,12 +2215,21 @@ function kebab(value) {
|
|
|
2032
2215
|
}
|
|
2033
2216
|
|
|
2034
2217
|
const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2218
|
+
const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2035
2219
|
const accountabilities = ['agent', 'subject-responsible', 'service-account'];
|
|
2036
2220
|
const conflicts = ['coalesce', 'queue', 'supersede', 'ignore'];
|
|
2037
2221
|
const maxDescription$1 = 300;
|
|
2038
2222
|
const maxInstructions = 20_000;
|
|
2039
2223
|
const maxMessage$1 = 2_000;
|
|
2040
2224
|
const maxReason$1 = 500;
|
|
2225
|
+
/** The platform's default file limits (`NhAssistantLimits`). */
|
|
2226
|
+
const defaultFileLimits = {
|
|
2227
|
+
maxFileBytes: 20 * 1024 * 1024,
|
|
2228
|
+
maxFilePages: 100,
|
|
2229
|
+
maxFilesPerTurn: 5,
|
|
2230
|
+
maxFileTokensPerTurn: 100_000
|
|
2231
|
+
};
|
|
2232
|
+
const fileLimitFields = ['maxFileBytes', 'maxFilePages', 'maxFilesPerTurn', 'maxFileTokensPerTurn'];
|
|
2041
2233
|
/**
|
|
2042
2234
|
* In-memory run configuration as data of the mock API: run settings of agents created in the
|
|
2043
2235
|
* administration, skills defined as data with versions and candidates, and data triggers. Saving
|
|
@@ -2072,13 +2264,16 @@ class NhAssistantMockRunConfiguration {
|
|
|
2072
2264
|
})),
|
|
2073
2265
|
accountSearch: accounts.searchable,
|
|
2074
2266
|
agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
|
|
2075
|
-
divisions: scenario.runCatalog?.divisions ?? false
|
|
2267
|
+
divisions: scenario.runCatalog?.divisions ?? false,
|
|
2268
|
+
// The platform's default file limits, unless the scenario's application chose its own.
|
|
2269
|
+
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
|
|
2076
2270
|
};
|
|
2077
2271
|
this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
|
|
2078
2272
|
...trigger,
|
|
2079
2273
|
skillId: trigger.skillId ?? null,
|
|
2080
2274
|
subjectType: trigger.subjectType ?? null
|
|
2081
2275
|
}));
|
|
2276
|
+
this.profileClassifications = scenario.profileClassifications;
|
|
2082
2277
|
this.codeRunSettings = new Map((scenario.agents ?? [])
|
|
2083
2278
|
.filter(agent => agent.source === 'code')
|
|
2084
2279
|
.map(agent => [agent.id, agent.codeRunSettings ?? {}]));
|
|
@@ -2091,6 +2286,10 @@ class NhAssistantMockRunConfiguration {
|
|
|
2091
2286
|
get divisionsListed() {
|
|
2092
2287
|
return this.catalog.divisions === true;
|
|
2093
2288
|
}
|
|
2289
|
+
/** The profiles the run catalog offers to data skills and to agents created in the administration. */
|
|
2290
|
+
get profileNames() {
|
|
2291
|
+
return [...this.catalog.profiles];
|
|
2292
|
+
}
|
|
2094
2293
|
/** `GET admin/run-catalog`. */
|
|
2095
2294
|
runCatalog(method) {
|
|
2096
2295
|
return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
|
|
@@ -2164,7 +2363,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2164
2363
|
accountability: given.accountability ?? (agent.serviceAccount ? 'service-account' : 'agent'),
|
|
2165
2364
|
delegateFromInitiator: given.delegateFromInitiator ?? false,
|
|
2166
2365
|
approvalPolicy: given.approvalPolicy ?? null,
|
|
2167
|
-
usesRules: given.usesRules ?? false
|
|
2366
|
+
usesRules: given.usesRules ?? false,
|
|
2367
|
+
startsOff: given.startsOff ?? false,
|
|
2368
|
+
acceptsFiles: given.acceptsFiles ?? false
|
|
2168
2369
|
};
|
|
2169
2370
|
}
|
|
2170
2371
|
/**
|
|
@@ -2227,6 +2428,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2227
2428
|
this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
|
|
2228
2429
|
this.requireKnown(errors, 'triageProfile', request.triageProfile, this.catalog.profiles);
|
|
2229
2430
|
this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
|
|
2431
|
+
this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
|
|
2230
2432
|
if (Object.keys(errors).length > 0) {
|
|
2231
2433
|
return mockFailure('assistant-validation', errors);
|
|
2232
2434
|
}
|
|
@@ -2256,7 +2458,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2256
2458
|
changedBy: 'current-user',
|
|
2257
2459
|
reason: text(request.reason),
|
|
2258
2460
|
inEffect: true,
|
|
2259
|
-
problem: null
|
|
2461
|
+
problem: null,
|
|
2462
|
+
acceptsFiles: request.acceptsFiles ?? false,
|
|
2463
|
+
...storedFileLimits(request)
|
|
2260
2464
|
};
|
|
2261
2465
|
this.settings.set(agent.id, saved);
|
|
2262
2466
|
return { status: 200, body: saved };
|
|
@@ -2316,9 +2520,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
2316
2520
|
if (request.apply === 'shadow' && (request.expectedVersion ?? null) === null) {
|
|
2317
2521
|
errors['apply'] = ['invalid'];
|
|
2318
2522
|
}
|
|
2523
|
+
// Only a new skill starts off; a skill that exists keeps its state.
|
|
2524
|
+
if (request.startsOff === true && (request.expectedVersion ?? null) !== null) {
|
|
2525
|
+
errors['startsOff'] = ['invalid'];
|
|
2526
|
+
}
|
|
2527
|
+
const classification = text(request.dataClassification);
|
|
2528
|
+
if (classification !== null && !dataClassifications.includes(classification)) {
|
|
2529
|
+
errors['dataClassification'] = ['invalid'];
|
|
2530
|
+
}
|
|
2319
2531
|
this.requireKnown(errors, 'profile', request.profile, this.catalog.profiles);
|
|
2320
2532
|
this.requireKnown(errors, 'ruleHandler', request.ruleHandler, this.catalog.ruleHandlers.map(handler => handler.name));
|
|
2321
2533
|
this.requireKnown(errors, 'approvalPolicy', request.approvalPolicy, this.catalog.approvalPolicies);
|
|
2534
|
+
this.requireFileLimits(errors, request.acceptsFiles ?? false, request);
|
|
2322
2535
|
if (Object.keys(errors).length > 0) {
|
|
2323
2536
|
return mockFailure('assistant-validation', errors);
|
|
2324
2537
|
}
|
|
@@ -2344,6 +2557,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2344
2557
|
instructionsHash: instructions === null ? null : hashText(instructions),
|
|
2345
2558
|
tools: [...new Set(request.tools ?? [])],
|
|
2346
2559
|
profile: text(request.profile),
|
|
2560
|
+
dataClassification: classification,
|
|
2561
|
+
acceptsFiles: request.acceptsFiles ?? false,
|
|
2562
|
+
...storedFileLimits(request),
|
|
2347
2563
|
ruleHandler: text(request.ruleHandler),
|
|
2348
2564
|
maxTokensPerRun: request.maxTokensPerRun ?? null,
|
|
2349
2565
|
dailyToolCalls: request.dailyToolCalls ?? null,
|
|
@@ -2357,9 +2573,10 @@ class NhAssistantMockRunConfiguration {
|
|
|
2357
2573
|
};
|
|
2358
2574
|
this.skills.set(key$1(agent.id, skillId), [...versions, saved]);
|
|
2359
2575
|
if (!candidate) {
|
|
2360
|
-
this.agents.setDataSkill(agent.id, skillId, saved.description);
|
|
2576
|
+
this.agents.setDataSkill(agent.id, skillId, saved.description, live === undefined && request.startsOff === true);
|
|
2361
2577
|
}
|
|
2362
|
-
|
|
2578
|
+
// A classification that no profile permits never refuses the save; the skill reports it.
|
|
2579
|
+
return { status: 200, body: this.withWarnings(agent, toSkill(saved)) };
|
|
2363
2580
|
}
|
|
2364
2581
|
candidate(method, agent, skillId, action, body) {
|
|
2365
2582
|
const versions = this.skills.get(key$1(agent.id, skillId)) ?? [];
|
|
@@ -2403,7 +2620,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2403
2620
|
}
|
|
2404
2621
|
candidate.status = 'live';
|
|
2405
2622
|
this.agents.setDataSkill(agent.id, skillId, candidate.description);
|
|
2406
|
-
return { status: 200, body: toSkill(candidate) };
|
|
2623
|
+
return { status: 200, body: this.withWarnings(agent, toSkill(candidate)) };
|
|
2407
2624
|
}
|
|
2408
2625
|
versions(agentId, skillId) {
|
|
2409
2626
|
return [...(this.skills.get(key$1(agentId, skillId)) ?? [])]
|
|
@@ -2547,6 +2764,7 @@ class NhAssistantMockRunConfiguration {
|
|
|
2547
2764
|
}
|
|
2548
2765
|
liveSkills(agentId) {
|
|
2549
2766
|
const result = [];
|
|
2767
|
+
const agent = this.agents.find(agentId);
|
|
2550
2768
|
for (const [stored, versions] of this.skills) {
|
|
2551
2769
|
if (!stored.startsWith(`${agentId}/`)) {
|
|
2552
2770
|
continue;
|
|
@@ -2554,11 +2772,24 @@ class NhAssistantMockRunConfiguration {
|
|
|
2554
2772
|
const live = versions.find(version => version.status === 'live');
|
|
2555
2773
|
if (live) {
|
|
2556
2774
|
const candidate = versions.find(version => version.status === 'candidate');
|
|
2557
|
-
result.push({
|
|
2775
|
+
result.push({
|
|
2776
|
+
...this.withWarnings(agent, toSkill(live)),
|
|
2777
|
+
candidate: candidate ? this.withWarnings(agent, toSkill(candidate)) : null
|
|
2778
|
+
});
|
|
2558
2779
|
}
|
|
2559
2780
|
}
|
|
2560
2781
|
return result.sort((left, right) => left.skillId.localeCompare(right.skillId));
|
|
2561
2782
|
}
|
|
2783
|
+
/**
|
|
2784
|
+
* The skill with the warnings the server reports for it: its classification, or else its agent's,
|
|
2785
|
+
* that its profile, or else its agent's, does not permit.
|
|
2786
|
+
*/
|
|
2787
|
+
withWarnings(agent, skill) {
|
|
2788
|
+
return {
|
|
2789
|
+
...skill,
|
|
2790
|
+
warnings: mockClassificationWarnings(this.profileClassifications, skill.profile ?? agent?.profile, skill.dataClassification ?? agent?.dataClassification)
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2562
2793
|
ownerOf(trigger) {
|
|
2563
2794
|
return trigger[ownerKey];
|
|
2564
2795
|
}
|
|
@@ -2574,6 +2805,28 @@ class NhAssistantMockRunConfiguration {
|
|
|
2574
2805
|
errors[field] = ['not-found'];
|
|
2575
2806
|
}
|
|
2576
2807
|
}
|
|
2808
|
+
/**
|
|
2809
|
+
* Lower file limits as the server checks them: each a whole number from 1 to the application's
|
|
2810
|
+
* limit, and only next to `acceptsFiles`.
|
|
2811
|
+
*/
|
|
2812
|
+
requireFileLimits(errors, acceptsFiles, request) {
|
|
2813
|
+
const application = this.catalog.fileLimits ?? defaultFileLimits;
|
|
2814
|
+
for (const field of fileLimitFields) {
|
|
2815
|
+
const value = request[field];
|
|
2816
|
+
if (value !== null && value !== undefined && (!acceptsFiles || !inRange(value, 1, application[field]))) {
|
|
2817
|
+
errors[field] = ['invalid'];
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
}
|
|
2822
|
+
/** The lower file limits a save stores, `null` where the request keeps the application's. */
|
|
2823
|
+
function storedFileLimits(request) {
|
|
2824
|
+
return {
|
|
2825
|
+
maxFileBytes: request.maxFileBytes ?? null,
|
|
2826
|
+
maxFilePages: request.maxFilePages ?? null,
|
|
2827
|
+
maxFilesPerTurn: request.maxFilesPerTurn ?? null,
|
|
2828
|
+
maxFileTokensPerTurn: request.maxFileTokensPerTurn ?? null
|
|
2829
|
+
};
|
|
2577
2830
|
}
|
|
2578
2831
|
const ownerKey = '__agentId';
|
|
2579
2832
|
function key$1(agentId, skillId) {
|
|
@@ -2649,12 +2902,13 @@ class NhAssistantMockAdmin {
|
|
|
2649
2902
|
this.agents = new NhAssistantMockAdminAgents(scenario, now, {
|
|
2650
2903
|
hasServer: serverId => this.mcp.hasServer(serverId),
|
|
2651
2904
|
agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
|
|
2652
|
-
divisionsListed: () => this.runConfiguration.divisionsListed
|
|
2905
|
+
divisionsListed: () => this.runConfiguration.divisionsListed,
|
|
2906
|
+
profileNames: () => this.runConfiguration.profileNames
|
|
2653
2907
|
});
|
|
2654
2908
|
this.accounts = new NhAssistantMockAccounts(admin);
|
|
2655
2909
|
this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
|
|
2656
2910
|
find: agentId => this.agents.find(agentId),
|
|
2657
|
-
setDataSkill: (agentId, skillId, description) => this.agents.setDataSkill(agentId, skillId, description)
|
|
2911
|
+
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
|
|
2658
2912
|
}, now, this.accounts);
|
|
2659
2913
|
this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
|
|
2660
2914
|
agents: () => this.agents.all(),
|
|
@@ -2859,6 +3113,159 @@ function toContext(version) {
|
|
|
2859
3113
|
return { text: version.text, version: version.version, hash: version.hash, updatedAt: version.updatedAt, updatedBy: version.updatedBy };
|
|
2860
3114
|
}
|
|
2861
3115
|
|
|
3116
|
+
/** The longest file name a reference takes, as on the server. */
|
|
3117
|
+
const maxNameLength = 256;
|
|
3118
|
+
const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
3119
|
+
/** How files of the platform's types start; other accepted types are the application's to check. */
|
|
3120
|
+
const signatures = {
|
|
3121
|
+
'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
3122
|
+
'image/png': head => startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
3123
|
+
'image/jpeg': head => startsWith(head, [0xff, 0xd8, 0xff]),
|
|
3124
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04]),
|
|
3125
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, [0x50, 0x4b, 0x03, 0x04])
|
|
3126
|
+
};
|
|
3127
|
+
/**
|
|
3128
|
+
* The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
|
|
3129
|
+
* attachments of a message, which a turn opens again as the server's file source would.
|
|
3130
|
+
*/
|
|
3131
|
+
class NhAssistantMockFiles {
|
|
3132
|
+
constructor(refuse, scan) {
|
|
3133
|
+
this.refuse = refuse;
|
|
3134
|
+
this.scan = scan;
|
|
3135
|
+
this.stored = new Map();
|
|
3136
|
+
this.next = 0;
|
|
3137
|
+
}
|
|
3138
|
+
/** The uploads the mock stored, oldest first. */
|
|
3139
|
+
get uploads() {
|
|
3140
|
+
return [...this.stored.values()];
|
|
3141
|
+
}
|
|
3142
|
+
/**
|
|
3143
|
+
* Checks an upload as the server does, in its order: a file source, an agent that accepts files,
|
|
3144
|
+
* the upload header, one file part named `file` with a name and content, an accepted media type
|
|
3145
|
+
* whose content matches, the agent's largest size, the application's scanner, and finally the
|
|
3146
|
+
* application's own refusal.
|
|
3147
|
+
*/
|
|
3148
|
+
async upload(conversationId, offered, uploads, headers, form) {
|
|
3149
|
+
if (!offered) {
|
|
3150
|
+
return mockFailure('assistant-files-unavailable');
|
|
3151
|
+
}
|
|
3152
|
+
if (!uploads) {
|
|
3153
|
+
return mockFailure('assistant-files-not-accepted');
|
|
3154
|
+
}
|
|
3155
|
+
const header = Object.entries(headers).find(([name]) => name.toLowerCase() === NH_ASSISTANT_UPLOAD_HEADER.toLowerCase());
|
|
3156
|
+
let parts = 0;
|
|
3157
|
+
form.forEach(() => parts++);
|
|
3158
|
+
const file = form.get('file');
|
|
3159
|
+
if (!header?.[1] || parts !== 1 || !(file instanceof Blob)) {
|
|
3160
|
+
return mockFailure('assistant-file-upload-invalid');
|
|
3161
|
+
}
|
|
3162
|
+
const name = nameOf(file instanceof File ? file.name : '');
|
|
3163
|
+
if (name === null || file.size === 0) {
|
|
3164
|
+
return mockFailure('assistant-file-upload-invalid');
|
|
3165
|
+
}
|
|
3166
|
+
const mediaType = normalizeMediaType(file.type);
|
|
3167
|
+
if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
|
|
3168
|
+
return mockFailure('assistant-file-type-not-accepted');
|
|
3169
|
+
}
|
|
3170
|
+
if (file.size > uploads.maxFileBytes) {
|
|
3171
|
+
return mockFailure('assistant-file-too-large');
|
|
3172
|
+
}
|
|
3173
|
+
const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
|
|
3174
|
+
if (!(signatures[mediaType]?.(head) ?? true)) {
|
|
3175
|
+
return mockFailure('assistant-file-type-not-accepted');
|
|
3176
|
+
}
|
|
3177
|
+
const upload = { conversationId, name, mediaType, size: file.size };
|
|
3178
|
+
// The application's scanner sees the file before its file source stores anything.
|
|
3179
|
+
const scanned = this.scan?.(upload) ?? null;
|
|
3180
|
+
if (scanned) {
|
|
3181
|
+
return mockFailure(scanned === 'refused' ? 'assistant-file-scan-refused' : 'assistant-file-upload-failed');
|
|
3182
|
+
}
|
|
3183
|
+
const refusal = this.refuse?.(upload) ?? null;
|
|
3184
|
+
if (refusal) {
|
|
3185
|
+
return mockFailure(refusalCode(refusal));
|
|
3186
|
+
}
|
|
3187
|
+
this.next++;
|
|
3188
|
+
const reference = { id: `mock-file-${this.next}`, name, mediaType, size: file.size, hash: null };
|
|
3189
|
+
this.stored.set(reference.id, { ...upload, reference });
|
|
3190
|
+
return { status: 200, body: reference };
|
|
3191
|
+
}
|
|
3192
|
+
/**
|
|
3193
|
+
* The code the server refuses a message's `files` with before the turn starts, or `null`: without
|
|
3194
|
+
* a file source, more files than one message carries, or a reference that is not one.
|
|
3195
|
+
*/
|
|
3196
|
+
checkMessage(files, offered, maxFiles) {
|
|
3197
|
+
if (files === undefined || files === null || (Array.isArray(files) && files.length === 0)) {
|
|
3198
|
+
return null;
|
|
3199
|
+
}
|
|
3200
|
+
if (!Array.isArray(files)) {
|
|
3201
|
+
return 'assistant-file-reference-invalid';
|
|
3202
|
+
}
|
|
3203
|
+
if (!offered) {
|
|
3204
|
+
return 'assistant-files-unavailable';
|
|
3205
|
+
}
|
|
3206
|
+
if (files.length > maxFiles) {
|
|
3207
|
+
return 'assistant-file-count-limit';
|
|
3208
|
+
}
|
|
3209
|
+
return files.every(isReference) ? null : 'assistant-file-reference-invalid';
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* The code of the first attachment the turn cannot open, as the server's file source refuses it,
|
|
3213
|
+
* or `null` when it opens them all. A reference the mock never stored, or one of another
|
|
3214
|
+
* conversation, is not found.
|
|
3215
|
+
*/
|
|
3216
|
+
openCode(conversationId, files) {
|
|
3217
|
+
const missing = files.find(file => this.stored.get(file.id)?.conversationId !== conversationId);
|
|
3218
|
+
return missing ? 'assistant-file-not-found' : null;
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
3221
|
+
/** The file name without a path; `null` when nothing usable is left, as on the server. */
|
|
3222
|
+
function nameOf(fileName) {
|
|
3223
|
+
const name = fileName.slice(Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\')) + 1).trim();
|
|
3224
|
+
return name.length === 0 || name.length > maxNameLength || /[\u0000-\u001f\u007f]/.test(name) ? null : name;
|
|
3225
|
+
}
|
|
3226
|
+
function normalizeMediaType(value) {
|
|
3227
|
+
const essence = value.split(';')[0].trim().toLowerCase();
|
|
3228
|
+
return essence.length <= 128 && mediaTypePattern.test(essence) ? essence : null;
|
|
3229
|
+
}
|
|
3230
|
+
function isReference(value) {
|
|
3231
|
+
const file = value;
|
|
3232
|
+
return !!file
|
|
3233
|
+
&& isText(file.id)
|
|
3234
|
+
&& isText(file.name)
|
|
3235
|
+
&& typeof file.mediaType === 'string' && normalizeMediaType(file.mediaType) !== null
|
|
3236
|
+
&& typeof file.size === 'number' && file.size >= 0;
|
|
3237
|
+
}
|
|
3238
|
+
function isText(value) {
|
|
3239
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= maxNameLength && !/[\u0000-\u001f\u007f]/.test(value);
|
|
3240
|
+
}
|
|
3241
|
+
/** The application's refusal as the server passes it on. */
|
|
3242
|
+
function refusalCode(code) {
|
|
3243
|
+
switch (code) {
|
|
3244
|
+
case 'assistant-files-unavailable':
|
|
3245
|
+
case 'assistant-file-too-large':
|
|
3246
|
+
return code;
|
|
3247
|
+
case 'assistant-file-type-not-accepted':
|
|
3248
|
+
case 'assistant-file-type-not-supported':
|
|
3249
|
+
return 'assistant-file-type-not-accepted';
|
|
3250
|
+
case 'assistant-file-upload-failed':
|
|
3251
|
+
case 'assistant-file-unavailable':
|
|
3252
|
+
return 'assistant-file-upload-failed';
|
|
3253
|
+
default:
|
|
3254
|
+
return 'assistant-file-upload-refused';
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
function startsWith(head, signature) {
|
|
3258
|
+
return head.length >= signature.length && signature.every((byte, index) => head[index] === byte);
|
|
3259
|
+
}
|
|
3260
|
+
function indexOf(head, signature) {
|
|
3261
|
+
for (let start = 0; start + signature.length <= head.length; start++) {
|
|
3262
|
+
if (signature.every((byte, index) => head[start + index] === byte)) {
|
|
3263
|
+
return start;
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
return -1;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
2862
3269
|
const dashCase$3 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2863
3270
|
const minQueryLength = 2;
|
|
2864
3271
|
const maxQueryLength = 100;
|
|
@@ -3829,9 +4236,9 @@ class NhAssistantMockAgentRuns {
|
|
|
3829
4236
|
const picked = localized ? mockPickText({ ...localized }, '', language) : null;
|
|
3830
4237
|
return picked ?? this.results.get(runId) ?? null;
|
|
3831
4238
|
}
|
|
3832
|
-
/** Neither stopped nor disabled in code; a paused agent still works for people. */
|
|
4239
|
+
/** Neither stopped nor disabled in code; a paused agent, and one that is off, still works for people. */
|
|
3833
4240
|
isWorking(agent) {
|
|
3834
|
-
return agent.runState === 'active' || agent.runState === 'paused';
|
|
4241
|
+
return agent.runState === 'active' || agent.runState === 'paused' || agent.runState === 'off';
|
|
3835
4242
|
}
|
|
3836
4243
|
mayUse(agent) {
|
|
3837
4244
|
return agent.requiredPolicy === null || agent.requiredPolicy === undefined || this.passes(agent.requiredPolicy);
|
|
@@ -4557,6 +4964,7 @@ class NhAssistantMockBackend {
|
|
|
4557
4964
|
this.shareTokens = new Map();
|
|
4558
4965
|
this.activeActors = new Map();
|
|
4559
4966
|
this.liveSubject = new Subject();
|
|
4967
|
+
this.files = new NhAssistantMockFiles(this.scenario.refuseUpload, this.scenario.scanUpload);
|
|
4560
4968
|
this.pushEnabled = true;
|
|
4561
4969
|
this.sequence = 0;
|
|
4562
4970
|
/** Requests received so far, oldest first. */
|
|
@@ -4570,12 +4978,45 @@ class NhAssistantMockBackend {
|
|
|
4570
4978
|
* provides it as `NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW`.
|
|
4571
4979
|
*/
|
|
4572
4980
|
this.authorizationWindow = new NhAssistantMockAuthorizationWindow(flowId => this.admin.authorizeFlow(flowId));
|
|
4981
|
+
/**
|
|
4982
|
+
* An upload implementation (`NH_ASSISTANT_UPLOAD`) that answers `POST conversations/{id}/files` with
|
|
4983
|
+
* the server's checks, after reporting the progress in four steps.
|
|
4984
|
+
*/
|
|
4985
|
+
this.upload = async (request) => {
|
|
4986
|
+
const path = this.pathOf(request.url);
|
|
4987
|
+
const file = request.body.get('file');
|
|
4988
|
+
this.requests.push({
|
|
4989
|
+
method: 'POST',
|
|
4990
|
+
path: path ?? request.url,
|
|
4991
|
+
headers: { ...request.headers },
|
|
4992
|
+
body: file instanceof Blob ? { name: file instanceof File ? file.name : null, mediaType: file.type, size: file.size } : undefined
|
|
4993
|
+
});
|
|
4994
|
+
const total = file instanceof Blob ? file.size : 0;
|
|
4995
|
+
for (const share of [0.25, 0.5, 0.75, 1]) {
|
|
4996
|
+
if (request.signal.aborted) {
|
|
4997
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
4998
|
+
}
|
|
4999
|
+
await delay(this.scenario.timing?.eventDelayMs ?? 40);
|
|
5000
|
+
request.onProgress(Math.round(total * share), total);
|
|
5001
|
+
}
|
|
5002
|
+
if (request.signal.aborted) {
|
|
5003
|
+
throw new DOMException('The operation was aborted.', 'AbortError');
|
|
5004
|
+
}
|
|
5005
|
+
const segments = path?.split('/').map(segment => decodeURIComponent(segment)) ?? [];
|
|
5006
|
+
if (!this.enabledState() || segments.length !== 3 || segments[0] !== 'conversations' || segments[2] !== 'files') {
|
|
5007
|
+
return this.empty(404);
|
|
5008
|
+
}
|
|
5009
|
+
const conversation = this.conversations.get(segments[1]);
|
|
5010
|
+
if (!conversation) {
|
|
5011
|
+
return this.error('assistant-conversation-not-found');
|
|
5012
|
+
}
|
|
5013
|
+
return this.result(await this.files.upload(conversation.id, this.offersFiles(), this.uploadsOf(conversation.agentId), request.headers, request.body));
|
|
5014
|
+
};
|
|
4573
5015
|
/** A `fetch` implementation that answers the assistant endpoints below `apiBaseUrl`. */
|
|
4574
5016
|
this.fetch = async (input, init) => {
|
|
4575
5017
|
const method = (init.method ?? 'GET').toUpperCase();
|
|
4576
5018
|
const url = new URL(input, 'http://mock.local');
|
|
4577
|
-
const
|
|
4578
|
-
const path = url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
|
|
5019
|
+
const path = this.pathOf(input);
|
|
4579
5020
|
const body = typeof init.body === 'string' && init.body.length > 0 ? JSON.parse(init.body) : undefined;
|
|
4580
5021
|
this.requests.push({ method, path: path ?? url.pathname, headers: { ...init.headers }, body });
|
|
4581
5022
|
if (init.signal?.aborted) {
|
|
@@ -4654,6 +5095,10 @@ class NhAssistantMockBackend {
|
|
|
4654
5095
|
setMcpConnectionStatus(serverId, status) {
|
|
4655
5096
|
this.admin.setMcpConnectionStatus(serverId, status);
|
|
4656
5097
|
}
|
|
5098
|
+
/** The files people uploaded, oldest first, as the application's file source stored them. */
|
|
5099
|
+
get uploads() {
|
|
5100
|
+
return this.files.uploads;
|
|
5101
|
+
}
|
|
4657
5102
|
route(method, path, query, body, language = null) {
|
|
4658
5103
|
const segments = path.split('/').map(segment => decodeURIComponent(segment));
|
|
4659
5104
|
if (method === 'GET' && path === 'status') {
|
|
@@ -4831,6 +5276,12 @@ class NhAssistantMockBackend {
|
|
|
4831
5276
|
if (text.length === 0 || text.length > limit || !request.clientMessageId) {
|
|
4832
5277
|
return this.empty(400);
|
|
4833
5278
|
}
|
|
5279
|
+
// Like the server: the attachments are checked before the turn claims the conversation.
|
|
5280
|
+
const uploads = this.uploadsOf(conversation.agentId);
|
|
5281
|
+
const filesCode = this.files.checkMessage(request.files, this.offersFiles(), uploads?.maxFiles ?? 5);
|
|
5282
|
+
if (filesCode) {
|
|
5283
|
+
return this.error(filesCode);
|
|
5284
|
+
}
|
|
4834
5285
|
if (!acceptsMessage(conversation.status)) {
|
|
4835
5286
|
return this.empty(409);
|
|
4836
5287
|
}
|
|
@@ -4840,6 +5291,14 @@ class NhAssistantMockBackend {
|
|
|
4840
5291
|
const turn = this.findTurn(text, conversation.agentId, pageContext);
|
|
4841
5292
|
const userMessageId = this.nextId();
|
|
4842
5293
|
const assistantMessageId = this.nextId();
|
|
5294
|
+
const files = request.files ?? [];
|
|
5295
|
+
// The turn opens the attachments with the sender's rights; it ends with the first refusal and the
|
|
5296
|
+
// message keeps only what the model read.
|
|
5297
|
+
const refusal = files.length === 0 ? null : uploads ? this.files.openCode(conversation.id, files) : 'assistant-files-not-accepted';
|
|
5298
|
+
const parts = [
|
|
5299
|
+
{ type: 'text', text },
|
|
5300
|
+
...(refusal ? [] : files.map(file => ({ type: 'file', name: file.name, mediaType: file.mediaType, size: file.size })))
|
|
5301
|
+
];
|
|
4843
5302
|
this.activeActors.set(conversation.id, this.currentUser.actorId);
|
|
4844
5303
|
this.update(conversation.id, current => ({
|
|
4845
5304
|
...current,
|
|
@@ -4847,14 +5306,17 @@ class NhAssistantMockBackend {
|
|
|
4847
5306
|
status: 'running',
|
|
4848
5307
|
messages: [
|
|
4849
5308
|
...current.messages,
|
|
4850
|
-
{ id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts
|
|
5309
|
+
{ id: userMessageId, role: 'user', createdAt: new Date().toISOString(), parts, authorActorId: this.currentUser.actorId }
|
|
4851
5310
|
]
|
|
4852
5311
|
}));
|
|
4853
5312
|
// The sender has read their own message.
|
|
4854
5313
|
this.readPositions.set(conversation.id, lastSequence(this.conversations.get(conversation.id)));
|
|
4855
5314
|
return this.stream(conversation.id, async (emit) => {
|
|
4856
5315
|
await emit({ type: 'turn.started', data: { turnId: this.nextId(), userMessageId, assistantMessageId } });
|
|
4857
|
-
|
|
5316
|
+
const steps = refusal
|
|
5317
|
+
? [{ error: { code: refusal, messageKey: `nh-assistant.errors.${refusal}` } }]
|
|
5318
|
+
: turn?.steps ?? [];
|
|
5319
|
+
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
4858
5320
|
});
|
|
4859
5321
|
}
|
|
4860
5322
|
decide(conversation, approvalId, request) {
|
|
@@ -5218,6 +5680,22 @@ class NhAssistantMockBackend {
|
|
|
5218
5680
|
error(code) {
|
|
5219
5681
|
return this.result(mockFailure(code));
|
|
5220
5682
|
}
|
|
5683
|
+
/** The path below `apiBaseUrl`, or `null` for another URL. */
|
|
5684
|
+
pathOf(input) {
|
|
5685
|
+
const url = new URL(input, 'http://mock.local');
|
|
5686
|
+
const base = new URL(this.config.apiBaseUrl.replace(/\/+$/, ''), 'http://mock.local').pathname;
|
|
5687
|
+
return url.pathname.startsWith(`${base}/`) ? url.pathname.slice(base.length + 1) : null;
|
|
5688
|
+
}
|
|
5689
|
+
/** Whether the application keeps files for the assistant: an agent of the scenario takes uploads. */
|
|
5690
|
+
offersFiles() {
|
|
5691
|
+
return this.scenario.agents.some(agent => !!agent.uploads);
|
|
5692
|
+
}
|
|
5693
|
+
/** What people may attach to a message to the agent, as its summary says. */
|
|
5694
|
+
uploadsOf(agentId) {
|
|
5695
|
+
return this.admin.chatAgents().find(agent => agent.id === agentId)?.uploads
|
|
5696
|
+
?? this.scenario.agents.find(agent => agent.id === agentId)?.uploads
|
|
5697
|
+
?? null;
|
|
5698
|
+
}
|
|
5221
5699
|
/**
|
|
5222
5700
|
* Whether the caller decides the agent's proposals of the whole application: an approver of the whole
|
|
5223
5701
|
* application (an administrator in the mock) who passes the agent's view policy, by default its
|
|
@@ -5381,15 +5859,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImpo
|
|
|
5381
5859
|
* Replaces the assistant transport with an in-memory back-end that plays `script`.
|
|
5382
5860
|
* Register it after `provideNhAssistant(...)` in the same injector. Every client feature
|
|
5383
5861
|
* (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.
|
|
5862
|
+
* state, live updates, uploads with their progress and MCP authorizations) then works without a
|
|
5863
|
+
* server. Inject `NhAssistantMockBackend` to switch the feature flag, simulate a colleague or
|
|
5864
|
+
* inspect the received requests and uploads.
|
|
5387
5865
|
*/
|
|
5388
5866
|
function provideNhAssistantMockApi(script) {
|
|
5389
5867
|
return makeEnvironmentProviders([
|
|
5390
5868
|
{ provide: NH_ASSISTANT_MOCK_SCENARIO, useValue: script },
|
|
5391
5869
|
NhAssistantMockBackend,
|
|
5392
5870
|
{ provide: NH_ASSISTANT_FETCH, useFactory: () => inject(NhAssistantMockBackend).fetch },
|
|
5871
|
+
{ provide: NH_ASSISTANT_UPLOAD, useFactory: () => inject(NhAssistantMockBackend).upload },
|
|
5393
5872
|
{ provide: NhAssistantLiveService, useClass: NhAssistantMockLiveService },
|
|
5394
5873
|
{ provide: NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW, useFactory: () => inject(NhAssistantMockBackend).authorizationWindow }
|
|
5395
5874
|
]);
|