@newheap/platform-ai-chat 0.16.0 → 0.18.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 +24 -1
- package/admin/index.d.ts +92 -6
- package/agent-runs/index.d.ts +188 -9
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +885 -157
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs +392 -40
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +1044 -61
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1074 -187
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +257 -13
- package/i18n/nl.json +257 -13
- package/index.d.ts +550 -32
- package/package.json +1 -1
- package/testing/index.d.ts +141 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { InjectionToken, inject, signal, Injectable, computed, DestroyRef, makeEnvironmentProviders } from '@angular/core';
|
|
3
|
-
import { NH_ASSISTANT_UPLOAD_HEADER, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
|
|
3
|
+
import { NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
|
|
4
4
|
import { Observable, Subject } from 'rxjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -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',
|
|
@@ -82,6 +83,7 @@ const statusTable = {
|
|
|
82
83
|
'assistant-board-not-found',
|
|
83
84
|
'assistant-artifact-not-found',
|
|
84
85
|
'assistant-trigger-not-found',
|
|
86
|
+
'assistant-consultation-not-found',
|
|
85
87
|
'assistant-not-found',
|
|
86
88
|
'assistant-context-not-found',
|
|
87
89
|
'assistant-mcp-server-not-found',
|
|
@@ -106,6 +108,12 @@ const statusTable = {
|
|
|
106
108
|
'assistant-moved-to-shadow',
|
|
107
109
|
'assistant-shadow-variant',
|
|
108
110
|
'assistant-service-account-not-used',
|
|
111
|
+
'assistant-consult-route-not-allowed',
|
|
112
|
+
'assistant-consult-proposal-refused',
|
|
113
|
+
'assistant-consult-asker-decides',
|
|
114
|
+
'assistant-consult-proposal-moved',
|
|
115
|
+
'assistant-consultation-in-code',
|
|
116
|
+
'assistant-consultations-not-allowed',
|
|
109
117
|
'assistant-skill-in-code',
|
|
110
118
|
'assistant-trigger-in-code',
|
|
111
119
|
'assistant-skill-in-use',
|
|
@@ -282,7 +290,7 @@ class NhAssistantMockAccounts {
|
|
|
282
290
|
|
|
283
291
|
/** The platform's type of the whole application. */
|
|
284
292
|
const mockSystemSubjectType = 'nh-system';
|
|
285
|
-
const subjectTypePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
293
|
+
const subjectTypePattern$1 = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
286
294
|
const maxSubjectTypes = 20;
|
|
287
295
|
const worksOnFields = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
|
|
288
296
|
/** An agent that does not say what it works on: every object type, not the application, app-wide. */
|
|
@@ -335,10 +343,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
|
|
|
335
343
|
if (types.length > maxSubjectTypes) {
|
|
336
344
|
add('worksOnSubjectTypes', 'too-long');
|
|
337
345
|
}
|
|
338
|
-
if (types.some(type => !subjectTypePattern.test(type))) {
|
|
346
|
+
if (types.some(type => !subjectTypePattern$1.test(type))) {
|
|
339
347
|
add('worksOnSubjectTypes', 'invalid');
|
|
340
348
|
}
|
|
341
|
-
if (types.some(type => subjectTypePattern.test(type) && (type.startsWith('nh-') || !knownTypes.includes(type)))) {
|
|
349
|
+
if (types.some(type => subjectTypePattern$1.test(type) && (type.startsWith('nh-') || !knownTypes.includes(type)))) {
|
|
342
350
|
add('worksOnSubjectTypes', 'not-found');
|
|
343
351
|
}
|
|
344
352
|
if (worksOn.perDivision && !divisions) {
|
|
@@ -363,6 +371,12 @@ function mockWorksOnCovers(agent, subjectType) {
|
|
|
363
371
|
return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
|
|
364
372
|
}
|
|
365
373
|
|
|
374
|
+
/** The routes in the order the server lists them, with the field of the request that switches each. */
|
|
375
|
+
const consultationRoutes = [
|
|
376
|
+
['asker-decides', 'askerDecides'],
|
|
377
|
+
['subject-agent-rights', 'subjectAgentRights'],
|
|
378
|
+
['subject-asker-rights', 'subjectAskerRights']
|
|
379
|
+
];
|
|
366
380
|
/** The profile of an agent that did not choose one, as the application's chat profile. */
|
|
367
381
|
const mockChatProfile = 'chat';
|
|
368
382
|
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
@@ -383,7 +397,7 @@ class NhAssistantMockAdminAgents {
|
|
|
383
397
|
const agents = admin.agents
|
|
384
398
|
?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
|
|
385
399
|
// How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
|
|
386
|
-
for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, codeRunSettings: _, runConfiguration: __, ...input } of agents) {
|
|
400
|
+
for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, ...input } of agents) {
|
|
387
401
|
const agentShadow = shadowOf !== undefined || shadow === true;
|
|
388
402
|
const initial = this.toAgent(input, source, 1, false);
|
|
389
403
|
this.agents.set(input.id, {
|
|
@@ -414,7 +428,11 @@ class NhAssistantMockAdminAgents {
|
|
|
414
428
|
shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
|
|
415
429
|
}))
|
|
416
430
|
},
|
|
417
|
-
codeDefinition: source === 'code' ? structuredClone(input) : null
|
|
431
|
+
codeDefinition: source === 'code' ? structuredClone(input) : null,
|
|
432
|
+
// Consultations in code belong to agents in code; every agent may get consultations as data.
|
|
433
|
+
consultations: noSwitches(source === 'code' && (consultations !== undefined || consultationDescriptions !== undefined)
|
|
434
|
+
? structuredClone(consultations ?? {})
|
|
435
|
+
: null)
|
|
418
436
|
});
|
|
419
437
|
}
|
|
420
438
|
}
|
|
@@ -455,7 +473,10 @@ class NhAssistantMockAdminAgents {
|
|
|
455
473
|
}
|
|
456
474
|
return result;
|
|
457
475
|
}
|
|
458
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* `admin/agents`, `admin/agents/{id}` and their `reset`, `state`, `shadow`, `service-account`,
|
|
478
|
+
* `consultation-routes` and `kill-switch`.
|
|
479
|
+
*/
|
|
459
480
|
agentsEndpoint(method, id, action, body, length) {
|
|
460
481
|
if (id === undefined) {
|
|
461
482
|
if (method === 'GET') {
|
|
@@ -473,7 +494,7 @@ class NhAssistantMockAdminAgents {
|
|
|
473
494
|
return mockFailure('assistant-agent-exists');
|
|
474
495
|
}
|
|
475
496
|
const agent = this.toAgent(input, 'admin', 1, false);
|
|
476
|
-
this.agents.set(agent.id, { agent, codeDefinition: null });
|
|
497
|
+
this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
|
|
477
498
|
return { status: 201, body: agent };
|
|
478
499
|
}
|
|
479
500
|
const stored = this.agents.get(id);
|
|
@@ -496,6 +517,9 @@ class NhAssistantMockAdminAgents {
|
|
|
496
517
|
if (length === 4 && action === 'service-account' && method === 'POST') {
|
|
497
518
|
return this.serviceAccountEndpoint(stored, body);
|
|
498
519
|
}
|
|
520
|
+
if (length === 4 && action === 'consultation-routes' && method === 'POST') {
|
|
521
|
+
return this.consultationRoutesEndpoint(stored, body);
|
|
522
|
+
}
|
|
499
523
|
if (length === 4 && action === 'kill-switch' && method === 'POST') {
|
|
500
524
|
const engaged = body?.engaged;
|
|
501
525
|
if (typeof engaged !== 'boolean') {
|
|
@@ -503,7 +527,7 @@ class NhAssistantMockAdminAgents {
|
|
|
503
527
|
}
|
|
504
528
|
// Releasing never switches on an agent that was off, as on the server: one that was off when
|
|
505
529
|
// it stopped goes back off, and one that is off stays off.
|
|
506
|
-
const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
|
|
530
|
+
const released = stored.agent.runState === 'off' || stored.agent.offBeforeStop === true ? 'off' : 'active';
|
|
507
531
|
const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
|
|
508
532
|
return changed.status === 200
|
|
509
533
|
? {
|
|
@@ -764,7 +788,7 @@ class NhAssistantMockAdminAgents {
|
|
|
764
788
|
skills: (stored.agent.skills ?? []).map(skill => ({ ...skill, shadow: withAgentShadow(skill.shadow, shadow) }))
|
|
765
789
|
};
|
|
766
790
|
}
|
|
767
|
-
return { status: 200, body: toAgentState(stored
|
|
791
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
768
792
|
}
|
|
769
793
|
/**
|
|
770
794
|
* `POST admin/agents/{id}/service-account`: the technical account of an agent that answers to
|
|
@@ -801,7 +825,96 @@ class NhAssistantMockAdminAgents {
|
|
|
801
825
|
}
|
|
802
826
|
};
|
|
803
827
|
}
|
|
804
|
-
return { status: 200, body: toAgentState(stored
|
|
828
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* `POST admin/agents/{id}/consultation-routes`: each route on, off or back to what the consultations
|
|
832
|
+
* allow; a route left out keeps its switch. The consultations in code and as data in effect are the
|
|
833
|
+
* ceiling, as on the server: switching on a route that none of them allows, or switching an agent
|
|
834
|
+
* nobody can consult, answers `409 assistant-consult-route-not-allowed`.
|
|
835
|
+
*/
|
|
836
|
+
consultationRoutesEndpoint(stored, body) {
|
|
837
|
+
const request = body;
|
|
838
|
+
if (!request || typeof request !== 'object') {
|
|
839
|
+
return mockValidationFailure();
|
|
840
|
+
}
|
|
841
|
+
for (const [, field] of consultationRoutes) {
|
|
842
|
+
const value = request[field];
|
|
843
|
+
if (value !== undefined && value !== null && value !== 'on' && value !== 'off' && value !== 'code') {
|
|
844
|
+
return mockValidationFailure(field);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
const reason = typeof request['reason'] === 'string' && request['reason'].trim().length > 0 ? request['reason'].trim() : null;
|
|
848
|
+
if ((request['reason'] !== undefined && request['reason'] !== null && typeof request['reason'] !== 'string')
|
|
849
|
+
|| (reason !== null && reason.length > mockMaxStateReason)) {
|
|
850
|
+
return mockValidationFailure('reason');
|
|
851
|
+
}
|
|
852
|
+
const current = stored.consultations;
|
|
853
|
+
const allowed = this.allowedRoutes(stored);
|
|
854
|
+
if (this.consultationRoutesState(stored) === null
|
|
855
|
+
|| !consultationRoutes.some(([route]) => (allowed[route]?.length ?? 0) > 0)
|
|
856
|
+
|| consultationRoutes.some(([route, field]) => request[field] === 'on' && (allowed[route]?.length ?? 0) === 0)) {
|
|
857
|
+
return mockFailure('assistant-consult-route-not-allowed');
|
|
858
|
+
}
|
|
859
|
+
const overrides = { ...current.overrides };
|
|
860
|
+
for (const [route, field] of consultationRoutes) {
|
|
861
|
+
const value = request[field];
|
|
862
|
+
if (value === 'on' || value === 'off') {
|
|
863
|
+
overrides[route] = value === 'on';
|
|
864
|
+
}
|
|
865
|
+
else if (value === 'code') {
|
|
866
|
+
delete overrides[route];
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const changed = consultationRoutes.some(([route]) => overrides[route] !== current.overrides[route]);
|
|
870
|
+
if (changed) {
|
|
871
|
+
stored.consultations = { ...current, overrides, changedAt: this.now(), changedBy: 'current-user', reason };
|
|
872
|
+
}
|
|
873
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* The subject types whose consultation allows each route: in code, and as data with the routes in
|
|
877
|
+
* effect, as the server reads both from the agent's options in effect.
|
|
878
|
+
*/
|
|
879
|
+
allowedRoutes(stored) {
|
|
880
|
+
const allowed = structuredClone(stored.consultations.allowed ?? {});
|
|
881
|
+
for (const consultation of this.rules.dataConsultations(stored.agent.id)) {
|
|
882
|
+
for (const route of consultation.routes) {
|
|
883
|
+
allowed[route] = [...(allowed[route] ?? []), consultation.subjectType];
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return allowed;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* The routes of an agent's consultations as the server reports them; `null` for an agent nobody can
|
|
890
|
+
* consult: it has no consultation in code and none as data in effect.
|
|
891
|
+
*/
|
|
892
|
+
consultationRoutesState(stored) {
|
|
893
|
+
const switches = stored.consultations;
|
|
894
|
+
if (switches.allowed === null && this.rules.dataConsultations(stored.agent.id).length === 0) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
const allowed = this.allowedRoutes(stored);
|
|
898
|
+
return {
|
|
899
|
+
routes: consultationRoutes.map(([route]) => {
|
|
900
|
+
const types = allowed[route] ?? [];
|
|
901
|
+
const override = switches.overrides[route] ?? null;
|
|
902
|
+
return {
|
|
903
|
+
route,
|
|
904
|
+
allowed: types.length > 0,
|
|
905
|
+
override,
|
|
906
|
+
on: types.length > 0 && override !== false,
|
|
907
|
+
subjectTypes: [...types]
|
|
908
|
+
};
|
|
909
|
+
}),
|
|
910
|
+
changedAt: switches.changedAt,
|
|
911
|
+
changedBy: switches.changedBy,
|
|
912
|
+
reason: switches.reason
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
/** The agent's state with the routes of its consultations. */
|
|
916
|
+
toAgentState(stored) {
|
|
917
|
+
return { ...toAgentState(stored), consultationRoutes: this.consultationRoutesState(stored) };
|
|
805
918
|
}
|
|
806
919
|
/**
|
|
807
920
|
* `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
|
|
@@ -810,7 +923,7 @@ class NhAssistantMockAdminAgents {
|
|
|
810
923
|
*/
|
|
811
924
|
agentStateEndpoint(stored, method, body) {
|
|
812
925
|
if (method === 'GET') {
|
|
813
|
-
return { status: 200, body: toAgentState(stored
|
|
926
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
814
927
|
}
|
|
815
928
|
if (method !== 'POST') {
|
|
816
929
|
return { status: 405 };
|
|
@@ -829,21 +942,21 @@ class NhAssistantMockAdminAgents {
|
|
|
829
942
|
if (stored.agent.runState === 'configured-off') {
|
|
830
943
|
return request.state === 'active'
|
|
831
944
|
? mockFailure('assistant-agent-configured-off')
|
|
832
|
-
: { status: 200, body: toAgentState(stored
|
|
945
|
+
: { status: 200, body: this.toAgentState(stored) };
|
|
833
946
|
}
|
|
834
947
|
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';
|
|
837
948
|
stored.agent = {
|
|
838
949
|
...stored.agent,
|
|
839
950
|
isEnabled: request.state !== 'stopped',
|
|
840
951
|
runState: request.state,
|
|
952
|
+
// A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
|
|
953
|
+
offBeforeStop: request.state === 'stopped' && stored.agent.runState === 'off',
|
|
841
954
|
stateChangedAt: this.now(),
|
|
842
955
|
stateChangedBy: 'current-user',
|
|
843
956
|
stateReason: reason
|
|
844
957
|
};
|
|
845
958
|
}
|
|
846
|
-
return { status: 200, body: toAgentState(stored
|
|
959
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
847
960
|
}
|
|
848
961
|
/**
|
|
849
962
|
* The contract failure for an invalid agent, or null when the agent is valid. What an agent created
|
|
@@ -924,6 +1037,7 @@ class NhAssistantMockAdminAgents {
|
|
|
924
1037
|
shadowOf: previous?.shadowOf ?? null,
|
|
925
1038
|
shadow: previous?.shadow ?? shadowState(false, false, null, false),
|
|
926
1039
|
serviceAccount: previous?.serviceAccount ?? null,
|
|
1040
|
+
offBeforeStop: previous?.offBeforeStop ?? false,
|
|
927
1041
|
// Code decides for its agents; an agent created here keeps its choice when it is omitted.
|
|
928
1042
|
offeredInChat: source === 'code' && previous
|
|
929
1043
|
? previous.offeredInChat ?? true
|
|
@@ -959,7 +1073,8 @@ function codeAgentFromSummary(agent) {
|
|
|
959
1073
|
autonomy: agent.canMutate ? 'execute' : 'explain'
|
|
960
1074
|
};
|
|
961
1075
|
}
|
|
962
|
-
function toAgentState(
|
|
1076
|
+
function toAgentState(stored) {
|
|
1077
|
+
const agent = stored.agent;
|
|
963
1078
|
return {
|
|
964
1079
|
agentId: agent.id,
|
|
965
1080
|
runState: agent.runState,
|
|
@@ -973,9 +1088,15 @@ function toAgentState(agent) {
|
|
|
973
1088
|
skills: agent.skills ?? [],
|
|
974
1089
|
shadowOf: agent.shadowOf ?? null,
|
|
975
1090
|
shadow: agent.shadow ?? shadowState(false, false, null, false),
|
|
976
|
-
serviceAccount: agent.serviceAccount ?? null
|
|
1091
|
+
serviceAccount: agent.serviceAccount ?? null,
|
|
1092
|
+
offBeforeStop: agent.runState === 'stopped' && agent.offBeforeStop === true,
|
|
1093
|
+
consultationRoutes: null
|
|
977
1094
|
};
|
|
978
1095
|
}
|
|
1096
|
+
/** The route switches of an agent nobody switched yet, with the routes its consultations in code allow. */
|
|
1097
|
+
function noSwitches(allowed) {
|
|
1098
|
+
return { allowed, overrides: {}, changedAt: null, changedBy: null, reason: null };
|
|
1099
|
+
}
|
|
979
1100
|
function shadowState(shadow, configured, override, locked) {
|
|
980
1101
|
return { shadow, configured, override, locked, changedAt: null, changedBy: null, reason: null };
|
|
981
1102
|
}
|
|
@@ -1033,7 +1154,7 @@ const maxReaders = 16;
|
|
|
1033
1154
|
const maxReaderMediaTypes = 16;
|
|
1034
1155
|
const maxReaderToolName = 128;
|
|
1035
1156
|
const readerPriority = { min: -1000, max: 1000 };
|
|
1036
|
-
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
1157
|
+
const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
1037
1158
|
const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
1038
1159
|
const controlCharacters = /[\u0000-\u001f\u007f]/;
|
|
1039
1160
|
/**
|
|
@@ -2106,7 +2227,7 @@ function checkReaders(input, stored, add) {
|
|
|
2106
2227
|
if (classification === null || classification === undefined) {
|
|
2107
2228
|
add(field('maxClassification'), 'required');
|
|
2108
2229
|
}
|
|
2109
|
-
else if (!classifications.includes(classification)) {
|
|
2230
|
+
else if (!classifications$1.includes(classification)) {
|
|
2110
2231
|
add(field('maxClassification'), 'invalid');
|
|
2111
2232
|
}
|
|
2112
2233
|
const priority = reader?.priority ?? 0;
|
|
@@ -2214,6 +2335,457 @@ function kebab(value) {
|
|
|
2214
2335
|
.toLowerCase();
|
|
2215
2336
|
}
|
|
2216
2337
|
|
|
2338
|
+
/** The bounds of `AllowConsultation`, within which a ceiling may only lower the maxima. */
|
|
2339
|
+
const bounds = {
|
|
2340
|
+
toolCalls: { min: 0, max: 32, initial: 4 },
|
|
2341
|
+
timeoutSeconds: { min: 1, max: 600, initial: 60 },
|
|
2342
|
+
answerChars: { min: 200, max: 32_000, initial: 4_000 },
|
|
2343
|
+
history: { min: 0, max: 200, initial: 20 }
|
|
2344
|
+
};
|
|
2345
|
+
const maxDescription$2 = 1_000;
|
|
2346
|
+
const maxInstructions$1 = 4_000;
|
|
2347
|
+
const maxConsultations = 20;
|
|
2348
|
+
const roles = ['reader', 'participant', 'approver'];
|
|
2349
|
+
const allRoutes = ['asker-decides', 'subject-agent-rights', 'subject-asker-rights'];
|
|
2350
|
+
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2351
|
+
const subjectTypePattern = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2352
|
+
/**
|
|
2353
|
+
* Consultations of the mock API's agents: those in code from the scenario, read-only, and those
|
|
2354
|
+
* defined as data, saved and removed as on the server within the ceilings in code
|
|
2355
|
+
* (`consultationsAsData` for the application, `consultableAsData` for an agent in code). A save
|
|
2356
|
+
* checks a consultation as the server does and refuses with the same codes and field errors; every
|
|
2357
|
+
* read checks it again, so a consultation that no longer fits, for example after the run settings
|
|
2358
|
+
* changed whom the agent answers to, stays with its problems. Data consultations in effect generate
|
|
2359
|
+
* their consult and request tools in the tool list and count for the agent's route switches.
|
|
2360
|
+
*/
|
|
2361
|
+
class NhAssistantMockConsultations {
|
|
2362
|
+
constructor(application, agents, rules, now) {
|
|
2363
|
+
this.rules = rules;
|
|
2364
|
+
this.now = now;
|
|
2365
|
+
this.stored = new Map();
|
|
2366
|
+
this.codeAllowed = new Map();
|
|
2367
|
+
this.codeDescriptions = new Map();
|
|
2368
|
+
this.agentCeilings = new Map();
|
|
2369
|
+
this.applicationCeiling = application ? ceilingOf(application) : null;
|
|
2370
|
+
for (const agent of agents) {
|
|
2371
|
+
if (agent.source !== 'code') {
|
|
2372
|
+
continue;
|
|
2373
|
+
}
|
|
2374
|
+
if (agent.consultations !== undefined || agent.consultationDescriptions !== undefined) {
|
|
2375
|
+
this.codeAllowed.set(agent.id, agent.consultations ?? {});
|
|
2376
|
+
this.codeDescriptions.set(agent.id, agent.consultationDescriptions ?? {});
|
|
2377
|
+
}
|
|
2378
|
+
if (agent.consultableAsData !== undefined) {
|
|
2379
|
+
this.agentCeilings.set(agent.id, agent.consultableAsData);
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* The ceiling of the agent: the application's for an agent created in the administration, the
|
|
2385
|
+
* application's and its own together for a consultable agent in code, none otherwise.
|
|
2386
|
+
*/
|
|
2387
|
+
ceilingOf(agent) {
|
|
2388
|
+
const application = this.applicationCeiling;
|
|
2389
|
+
if (!application) {
|
|
2390
|
+
return null;
|
|
2391
|
+
}
|
|
2392
|
+
if (agent.source === 'admin') {
|
|
2393
|
+
return application;
|
|
2394
|
+
}
|
|
2395
|
+
const own = this.agentCeilings.get(agent.id);
|
|
2396
|
+
if (!own) {
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
const narrowed = ceilingOf(own);
|
|
2400
|
+
return {
|
|
2401
|
+
...application,
|
|
2402
|
+
routes: application.routes.filter(route => narrowed.routes.includes(route)),
|
|
2403
|
+
allowRequests: application.allowRequests && narrowed.allowRequests,
|
|
2404
|
+
allowMcp: application.allowMcp && narrowed.allowMcp,
|
|
2405
|
+
maxToolCalls: Math.min(application.maxToolCalls, narrowed.maxToolCalls),
|
|
2406
|
+
maxTimeoutSeconds: Math.min(application.maxTimeoutSeconds, narrowed.maxTimeoutSeconds),
|
|
2407
|
+
maxAnswerChars: Math.min(application.maxAnswerChars, narrowed.maxAnswerChars),
|
|
2408
|
+
maxHistoryMessages: Math.min(application.maxHistoryMessages, narrowed.maxHistoryMessages)
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
/** The agent's consultations in code, by subject type. */
|
|
2412
|
+
codeConsultations(agent) {
|
|
2413
|
+
const allowed = this.codeAllowed.get(agent.id);
|
|
2414
|
+
if (!allowed) {
|
|
2415
|
+
return [];
|
|
2416
|
+
}
|
|
2417
|
+
const descriptions = this.codeDescriptions.get(agent.id) ?? {};
|
|
2418
|
+
const types = new Set([...Object.values(allowed).flat(), ...Object.keys(descriptions)]);
|
|
2419
|
+
return [...types].sort().map(subjectType => {
|
|
2420
|
+
const consultToolId = consultToolOf(agent.id, subjectType);
|
|
2421
|
+
return {
|
|
2422
|
+
subjectType,
|
|
2423
|
+
description: descriptions[subjectType] ?? null,
|
|
2424
|
+
allowedRoutes: allRoutes.filter(route => (allowed[route] ?? []).includes(subjectType)),
|
|
2425
|
+
allowRequests: false,
|
|
2426
|
+
exposure: ['local', 'agent'],
|
|
2427
|
+
consultToolId,
|
|
2428
|
+
requestToolId: null,
|
|
2429
|
+
askingAgents: this.askersOf(agent.id, consultToolId)
|
|
2430
|
+
};
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
/** The agent's consultations defined as data, by subject type, each with what is in effect of it. */
|
|
2434
|
+
dataConsultations(agent) {
|
|
2435
|
+
return this.storedOf(agent.id).map(stored => this.toConsultation(agent, stored));
|
|
2436
|
+
}
|
|
2437
|
+
/** The problems of kind `consultation` of the agent's data consultations. */
|
|
2438
|
+
problems(agent) {
|
|
2439
|
+
return this.storedOf(agent.id).flatMap(stored => this.check(agent, stored).problems.map(code => ({
|
|
2440
|
+
agentId: agent.id,
|
|
2441
|
+
kind: 'consultation',
|
|
2442
|
+
id: stored.subjectType,
|
|
2443
|
+
code
|
|
2444
|
+
})));
|
|
2445
|
+
}
|
|
2446
|
+
/** The agent's data consultations in effect with their routes in effect, for its route switches. */
|
|
2447
|
+
inEffect(agentId) {
|
|
2448
|
+
const agent = this.rules.find(agentId);
|
|
2449
|
+
if (!agent) {
|
|
2450
|
+
return [];
|
|
2451
|
+
}
|
|
2452
|
+
return this.storedOf(agentId)
|
|
2453
|
+
.map(stored => ({ subjectType: stored.subjectType, check: this.check(agent, stored) }))
|
|
2454
|
+
.filter(item => item.check.inEffect)
|
|
2455
|
+
.map(item => ({ subjectType: item.subjectType, routes: item.check.routes }));
|
|
2456
|
+
}
|
|
2457
|
+
/** Whether data consultations belong to the agent, which keeps its run settings. */
|
|
2458
|
+
has(agentId) {
|
|
2459
|
+
return this.storedOf(agentId).length > 0;
|
|
2460
|
+
}
|
|
2461
|
+
/** The generated tools of the data consultations in effect, as `GET admin/tools` lists them. */
|
|
2462
|
+
tools() {
|
|
2463
|
+
const tools = [];
|
|
2464
|
+
for (const stored of [...this.stored.values()].sort((left, right) => key$2(left.agentId, left.subjectType).localeCompare(key$2(right.agentId, right.subjectType)))) {
|
|
2465
|
+
const agent = this.rules.find(stored.agentId);
|
|
2466
|
+
const check = agent ? this.check(agent, stored) : null;
|
|
2467
|
+
if (!check?.inEffect) {
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
tools.push({ id: consultToolOf(stored.agentId, stored.subjectType), source: 'local', effect: 'read-only', description: stored.description, subjectType: null });
|
|
2471
|
+
if (check.allowRequests) {
|
|
2472
|
+
tools.push({ id: requestToolOf(stored.agentId, stored.subjectType), source: 'local', effect: 'idempotent-mutation', description: stored.description, subjectType: null });
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
return tools;
|
|
2476
|
+
}
|
|
2477
|
+
/** `PUT` and `DELETE admin/agents/{id}/consultations/{subjectType}`. */
|
|
2478
|
+
handle(method, agent, subjectType, body, query) {
|
|
2479
|
+
if (method === 'DELETE') {
|
|
2480
|
+
return this.remove(agent, subjectType, query);
|
|
2481
|
+
}
|
|
2482
|
+
return method === 'PUT' ? this.save(agent, subjectType, body) : { status: 405 };
|
|
2483
|
+
}
|
|
2484
|
+
save(agent, subjectType, body) {
|
|
2485
|
+
const request = (body ?? {});
|
|
2486
|
+
const reason = text$1(request.reason);
|
|
2487
|
+
if (reason !== null && reason.length > mockMaxStateReason) {
|
|
2488
|
+
return mockFailure('assistant-validation', { reason: ['too-long'] });
|
|
2489
|
+
}
|
|
2490
|
+
const ceiling = this.ceilingOf(agent);
|
|
2491
|
+
if (!ceiling) {
|
|
2492
|
+
return mockFailure('assistant-consultations-not-allowed');
|
|
2493
|
+
}
|
|
2494
|
+
if (!this.rules.isConfigured(agent)) {
|
|
2495
|
+
return mockFailure('assistant-agent-not-configured');
|
|
2496
|
+
}
|
|
2497
|
+
if (this.inCode(agent.id, subjectType)) {
|
|
2498
|
+
return mockFailure('assistant-consultation-in-code');
|
|
2499
|
+
}
|
|
2500
|
+
// A new consultation starts with the defaults of code, capped by the ceiling.
|
|
2501
|
+
const description = text$1(request.description);
|
|
2502
|
+
const instructions = text$1(request.instructions);
|
|
2503
|
+
const minimumRole = (text$1(request.minimumRole) ?? 'reader');
|
|
2504
|
+
const proposalRole = (text$1(request.proposalRole) ?? 'participant');
|
|
2505
|
+
const routes = (request.routes ?? []).map(route => (typeof route === 'string' ? route.trim() : ''));
|
|
2506
|
+
const classification = text$1(request.dataClassification);
|
|
2507
|
+
const limits = {
|
|
2508
|
+
maxToolCalls: request.maxToolCalls ?? Math.min(bounds.toolCalls.initial, ceiling.maxToolCalls),
|
|
2509
|
+
timeoutSeconds: request.timeoutSeconds ?? Math.min(bounds.timeoutSeconds.initial, ceiling.maxTimeoutSeconds),
|
|
2510
|
+
maxAnswerChars: request.maxAnswerChars ?? Math.min(bounds.answerChars.initial, ceiling.maxAnswerChars),
|
|
2511
|
+
maxHistoryMessages: request.maxHistoryMessages ?? Math.min(bounds.history.initial, ceiling.maxHistoryMessages)
|
|
2512
|
+
};
|
|
2513
|
+
const errors = {};
|
|
2514
|
+
const add = (field, code) => {
|
|
2515
|
+
errors[field] = [...(errors[field] ?? []), code];
|
|
2516
|
+
};
|
|
2517
|
+
if (!subjectTypePattern.test(subjectType)) {
|
|
2518
|
+
add('subjectType', 'invalid');
|
|
2519
|
+
}
|
|
2520
|
+
if (description === null) {
|
|
2521
|
+
add('description', 'required');
|
|
2522
|
+
}
|
|
2523
|
+
else if (description.length > maxDescription$2) {
|
|
2524
|
+
add('description', 'too-long');
|
|
2525
|
+
}
|
|
2526
|
+
if ((instructions ?? '').length > maxInstructions$1) {
|
|
2527
|
+
add('instructions', 'too-long');
|
|
2528
|
+
}
|
|
2529
|
+
if (!roles.includes(minimumRole)) {
|
|
2530
|
+
add('minimumRole', 'invalid');
|
|
2531
|
+
}
|
|
2532
|
+
if (!roles.includes(proposalRole)) {
|
|
2533
|
+
add('proposalRole', 'invalid');
|
|
2534
|
+
}
|
|
2535
|
+
if (!validRoutes(routes)) {
|
|
2536
|
+
add('routes', 'invalid');
|
|
2537
|
+
}
|
|
2538
|
+
if (!within(limits.maxToolCalls, bounds.toolCalls)
|
|
2539
|
+
|| !within(limits.timeoutSeconds, bounds.timeoutSeconds)
|
|
2540
|
+
|| !within(limits.maxAnswerChars, bounds.answerChars)
|
|
2541
|
+
|| !within(limits.maxHistoryMessages, bounds.history)) {
|
|
2542
|
+
add('limits', 'invalid');
|
|
2543
|
+
}
|
|
2544
|
+
if (classification !== null && !classifications.includes(classification)) {
|
|
2545
|
+
add('dataClassification', 'invalid');
|
|
2546
|
+
}
|
|
2547
|
+
if (Object.keys(errors).length > 0) {
|
|
2548
|
+
return mockFailure('assistant-validation', errors);
|
|
2549
|
+
}
|
|
2550
|
+
const existing = this.stored.get(key$2(agent.id, subjectType));
|
|
2551
|
+
const now = this.now();
|
|
2552
|
+
const row = {
|
|
2553
|
+
agentId: agent.id,
|
|
2554
|
+
subjectType,
|
|
2555
|
+
version: (existing?.version ?? 0) + 1,
|
|
2556
|
+
description: description,
|
|
2557
|
+
instructions,
|
|
2558
|
+
minimumRole,
|
|
2559
|
+
askerPolicy: text$1(request.askerPolicy),
|
|
2560
|
+
routes,
|
|
2561
|
+
proposalRole,
|
|
2562
|
+
proposalPolicy: text$1(request.proposalPolicy),
|
|
2563
|
+
...limits,
|
|
2564
|
+
allowRequests: request.allowRequests === true,
|
|
2565
|
+
exposeToMcp: request.exposeToMcp === true,
|
|
2566
|
+
dataClassification: classification,
|
|
2567
|
+
createdAt: existing?.createdAt ?? now,
|
|
2568
|
+
createdBy: existing?.createdBy ?? 'current-user',
|
|
2569
|
+
changedAt: now,
|
|
2570
|
+
changedBy: 'current-user',
|
|
2571
|
+
reason
|
|
2572
|
+
};
|
|
2573
|
+
// The same check as every read, refused here instead of degraded.
|
|
2574
|
+
const check = this.check(agent, row);
|
|
2575
|
+
if (check.problems.length > 0) {
|
|
2576
|
+
return fieldFailure(check.problems);
|
|
2577
|
+
}
|
|
2578
|
+
if (request.expectedVersion == null && !existing && this.storedOf(agent.id).length >= maxConsultations) {
|
|
2579
|
+
return mockFailure('assistant-configuration-limit');
|
|
2580
|
+
}
|
|
2581
|
+
if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
|
|
2582
|
+
return mockFailure('assistant-version-conflict');
|
|
2583
|
+
}
|
|
2584
|
+
this.stored.set(key$2(agent.id, subjectType), row);
|
|
2585
|
+
return { status: 200, body: this.toConsultation(agent, row) };
|
|
2586
|
+
}
|
|
2587
|
+
remove(agent, subjectType, query) {
|
|
2588
|
+
const reason = query.get('reason');
|
|
2589
|
+
if (reason !== null && reason.trim().length > mockMaxStateReason) {
|
|
2590
|
+
return mockFailure('assistant-validation', { reason: ['too-long'] });
|
|
2591
|
+
}
|
|
2592
|
+
if (this.inCode(agent.id, subjectType)) {
|
|
2593
|
+
return mockFailure('assistant-consultation-in-code');
|
|
2594
|
+
}
|
|
2595
|
+
const existing = this.stored.get(key$2(agent.id, subjectType));
|
|
2596
|
+
if (!existing) {
|
|
2597
|
+
return mockFailure('assistant-consultation-not-found');
|
|
2598
|
+
}
|
|
2599
|
+
const expected = query.get('expectedVersion');
|
|
2600
|
+
if (expected !== null && Number(expected) !== existing.version) {
|
|
2601
|
+
return mockFailure('assistant-version-conflict');
|
|
2602
|
+
}
|
|
2603
|
+
this.stored.delete(key$2(agent.id, subjectType));
|
|
2604
|
+
return { status: 204 };
|
|
2605
|
+
}
|
|
2606
|
+
/**
|
|
2607
|
+
* Checks a consultation against the ceilings and the agent as the server does on save and on every
|
|
2608
|
+
* load: out of effect when the agent may get none, the type is unknown or not worked on or the
|
|
2609
|
+
* asker policy is no longer listed; otherwise without the routes, requests and MCP exposure that
|
|
2610
|
+
* no longer fit and with limits capped at the ceiling, each with a problem.
|
|
2611
|
+
*/
|
|
2612
|
+
check(agent, row) {
|
|
2613
|
+
const out = (problem) => ({ inEffect: false, problems: [problem], routes: [], allowRequests: false });
|
|
2614
|
+
const ceiling = this.ceilingOf(agent);
|
|
2615
|
+
if (!ceiling) {
|
|
2616
|
+
return out('not-consultable');
|
|
2617
|
+
}
|
|
2618
|
+
if (!this.rules.knownSubjectTypes().includes(row.subjectType)) {
|
|
2619
|
+
return out('unknown-subject-type');
|
|
2620
|
+
}
|
|
2621
|
+
if (!mockWorksOnCovers(agent, row.subjectType)) {
|
|
2622
|
+
return out('not-worked-on');
|
|
2623
|
+
}
|
|
2624
|
+
if (this.inCode(agent.id, row.subjectType)) {
|
|
2625
|
+
return out('conflicts-with-code');
|
|
2626
|
+
}
|
|
2627
|
+
if (row.askerPolicy !== null && !this.rules.agentPolicyNames().includes(row.askerPolicy)) {
|
|
2628
|
+
return out('unknown-agent-policy');
|
|
2629
|
+
}
|
|
2630
|
+
const problems = [];
|
|
2631
|
+
const addOnce = (problem) => {
|
|
2632
|
+
if (!problems.includes(problem)) {
|
|
2633
|
+
problems.push(problem);
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
const accountability = this.rules.accountabilityOf(agent);
|
|
2637
|
+
let kept = [];
|
|
2638
|
+
for (const route of row.routes) {
|
|
2639
|
+
const agentRights = route === 'subject-agent-rights';
|
|
2640
|
+
if (!ceiling.routes.includes(route) || (agentRights && row.subjectType === mockSystemSubjectType)) {
|
|
2641
|
+
addOnce('route-not-allowed');
|
|
2642
|
+
continue;
|
|
2643
|
+
}
|
|
2644
|
+
if (agentRights && accountability === 'subject-responsible') {
|
|
2645
|
+
addOnce('agent-answers-to-person');
|
|
2646
|
+
continue;
|
|
2647
|
+
}
|
|
2648
|
+
// An agent created here that answers for itself has no grants; one in code is assumed to have them.
|
|
2649
|
+
if (agentRights && agent.source === 'admin' && accountability === 'agent') {
|
|
2650
|
+
addOnce('agent-has-no-rights');
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
2653
|
+
kept.push(route);
|
|
2654
|
+
}
|
|
2655
|
+
if (row.proposalPolicy !== null && !this.rules.approvalPolicies().includes(row.proposalPolicy)) {
|
|
2656
|
+
// Without its policy nobody knows who decides a proposal: the consultation only answers.
|
|
2657
|
+
addOnce('unknown-policy');
|
|
2658
|
+
kept = [];
|
|
2659
|
+
}
|
|
2660
|
+
const policies = [row.proposalPolicy, this.rules.approvalPolicyOf(agent)].filter((policy) => policy !== null);
|
|
2661
|
+
if (kept.includes('subject-agent-rights') && policies.some(policy => this.namesOnlyTheInitiator(policy))) {
|
|
2662
|
+
addOnce('no-approver');
|
|
2663
|
+
kept = kept.filter(route => route !== 'subject-agent-rights');
|
|
2664
|
+
}
|
|
2665
|
+
let allowRequests = row.allowRequests;
|
|
2666
|
+
if (allowRequests && !ceiling.allowRequests) {
|
|
2667
|
+
problems.push('requests-not-allowed');
|
|
2668
|
+
allowRequests = false;
|
|
2669
|
+
}
|
|
2670
|
+
if (row.exposeToMcp && !ceiling.allowMcp) {
|
|
2671
|
+
problems.push('mcp-not-allowed');
|
|
2672
|
+
}
|
|
2673
|
+
if (row.maxToolCalls > ceiling.maxToolCalls
|
|
2674
|
+
|| row.timeoutSeconds > ceiling.maxTimeoutSeconds
|
|
2675
|
+
|| row.maxAnswerChars > ceiling.maxAnswerChars
|
|
2676
|
+
|| row.maxHistoryMessages > ceiling.maxHistoryMessages) {
|
|
2677
|
+
problems.push('limit-above-ceiling');
|
|
2678
|
+
}
|
|
2679
|
+
return { inEffect: true, problems, routes: kept, allowRequests };
|
|
2680
|
+
}
|
|
2681
|
+
namesOnlyTheInitiator(policy) {
|
|
2682
|
+
const summary = this.rules.approvalPolicySummaries().find(item => item.name === policy);
|
|
2683
|
+
return summary !== undefined && summary.approvers.length > 0 && summary.approvers.every(approver => approver.kind === 'initiator');
|
|
2684
|
+
}
|
|
2685
|
+
toConsultation(agent, row) {
|
|
2686
|
+
const check = this.check(agent, row);
|
|
2687
|
+
const consultToolId = consultToolOf(agent.id, row.subjectType);
|
|
2688
|
+
const { agentId, ...stored } = row;
|
|
2689
|
+
return {
|
|
2690
|
+
...stored,
|
|
2691
|
+
routes: [...row.routes],
|
|
2692
|
+
instructionsHash: row.instructions === null ? null : mockHashText(row.instructions),
|
|
2693
|
+
inEffect: check.inEffect,
|
|
2694
|
+
problem: check.problems[0] ?? null,
|
|
2695
|
+
problems: check.problems,
|
|
2696
|
+
routesInEffect: check.routes,
|
|
2697
|
+
consultToolId,
|
|
2698
|
+
requestToolId: check.allowRequests ? requestToolOf(agent.id, row.subjectType) : null,
|
|
2699
|
+
askingAgents: this.askersOf(agent.id, consultToolId)
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
/** The other agents whose tool selectors select the tool now, by id: the consultation's second key. */
|
|
2703
|
+
askersOf(agentId, toolId) {
|
|
2704
|
+
return this.rules.all()
|
|
2705
|
+
.filter(agent => agent.id !== agentId && agent.toolSelectors.some(selector => covers$1(selector, toolId)))
|
|
2706
|
+
.map(agent => agent.id)
|
|
2707
|
+
.sort();
|
|
2708
|
+
}
|
|
2709
|
+
inCode(agentId, subjectType) {
|
|
2710
|
+
const allowed = this.codeAllowed.get(agentId);
|
|
2711
|
+
return allowed !== undefined
|
|
2712
|
+
&& (Object.values(allowed).some(types => types?.includes(subjectType)) || subjectType in (this.codeDescriptions.get(agentId) ?? {}));
|
|
2713
|
+
}
|
|
2714
|
+
storedOf(agentId) {
|
|
2715
|
+
return [...this.stored.values()]
|
|
2716
|
+
.filter(stored => stored.agentId === agentId)
|
|
2717
|
+
.sort((left, right) => left.subjectType.localeCompare(right.subjectType));
|
|
2718
|
+
}
|
|
2719
|
+
}
|
|
2720
|
+
/** A ceiling of the scenario with the defaults of the ceiling builder. */
|
|
2721
|
+
function ceilingOf(ceiling) {
|
|
2722
|
+
return {
|
|
2723
|
+
routes: [...(ceiling.routes ?? [])],
|
|
2724
|
+
allowRequests: ceiling.allowRequests ?? false,
|
|
2725
|
+
allowMcp: ceiling.allowMcp ?? false,
|
|
2726
|
+
maxToolCalls: ceiling.maxToolCalls ?? bounds.toolCalls.max,
|
|
2727
|
+
maxTimeoutSeconds: ceiling.maxTimeoutSeconds ?? bounds.timeoutSeconds.max,
|
|
2728
|
+
maxAnswerChars: ceiling.maxAnswerChars ?? bounds.answerChars.max,
|
|
2729
|
+
maxHistoryMessages: ceiling.maxHistoryMessages ?? bounds.history.max,
|
|
2730
|
+
maxConsultations,
|
|
2731
|
+
maxInstructionsLength: maxInstructions$1
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
/** The field error of each problem a consultation would have, as the server maps them on save. */
|
|
2735
|
+
function fieldFailure(problems) {
|
|
2736
|
+
if (problems.includes('not-consultable')) {
|
|
2737
|
+
return mockFailure('assistant-consultations-not-allowed');
|
|
2738
|
+
}
|
|
2739
|
+
if (problems.includes('conflicts-with-code')) {
|
|
2740
|
+
return mockFailure('assistant-consultation-in-code');
|
|
2741
|
+
}
|
|
2742
|
+
const fields = {
|
|
2743
|
+
'unknown-subject-type': ['subjectType', 'not-found'],
|
|
2744
|
+
'not-worked-on': ['subjectType', 'invalid'],
|
|
2745
|
+
'unknown-agent-policy': ['askerPolicy', 'not-found'],
|
|
2746
|
+
'route-not-allowed': ['routes', 'not-allowed'],
|
|
2747
|
+
'agent-answers-to-person': ['routes', 'invalid'],
|
|
2748
|
+
'agent-has-no-rights': ['routes', 'invalid'],
|
|
2749
|
+
'unknown-policy': ['proposalPolicy', 'not-found'],
|
|
2750
|
+
'no-approver': ['proposalPolicy', 'invalid'],
|
|
2751
|
+
'limit-above-ceiling': ['limits', 'not-allowed'],
|
|
2752
|
+
'requests-not-allowed': ['allowRequests', 'not-allowed'],
|
|
2753
|
+
'mcp-not-allowed': ['exposeToMcp', 'not-allowed']
|
|
2754
|
+
};
|
|
2755
|
+
const errors = {};
|
|
2756
|
+
for (const problem of problems) {
|
|
2757
|
+
const [field, code] = fields[problem] ?? ['subjectType', 'invalid'];
|
|
2758
|
+
errors[field] = [...(errors[field] ?? []), code];
|
|
2759
|
+
}
|
|
2760
|
+
return mockFailure('assistant-validation', errors);
|
|
2761
|
+
}
|
|
2762
|
+
/** Known routes, each once, and at most one of the two routes at the subject. */
|
|
2763
|
+
function validRoutes(routes) {
|
|
2764
|
+
return routes.every(route => allRoutes.includes(route))
|
|
2765
|
+
&& new Set(routes).size === routes.length
|
|
2766
|
+
&& !(routes.includes('subject-agent-rights') && routes.includes('subject-asker-rights'));
|
|
2767
|
+
}
|
|
2768
|
+
function within(value, range) {
|
|
2769
|
+
return Number.isInteger(value) && value >= range.min && value <= range.max;
|
|
2770
|
+
}
|
|
2771
|
+
function consultToolOf(agentId, subjectType) {
|
|
2772
|
+
return `assistant.consult.${agentId}.${subjectType}`;
|
|
2773
|
+
}
|
|
2774
|
+
function requestToolOf(agentId, subjectType) {
|
|
2775
|
+
return `assistant.request.${agentId}.${subjectType}`;
|
|
2776
|
+
}
|
|
2777
|
+
/** The agent's selector covers the tool: the same tool, or a pattern with `*` that matches it. */
|
|
2778
|
+
function covers$1(selector, toolId) {
|
|
2779
|
+
const pattern = selector.split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*');
|
|
2780
|
+
return new RegExp(`^${pattern}$`).test(toolId);
|
|
2781
|
+
}
|
|
2782
|
+
function key$2(agentId, subjectType) {
|
|
2783
|
+
return `${agentId}/${subjectType}`;
|
|
2784
|
+
}
|
|
2785
|
+
function text$1(value) {
|
|
2786
|
+
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
2787
|
+
}
|
|
2788
|
+
|
|
2217
2789
|
const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2218
2790
|
const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2219
2791
|
const accountabilities = ['agent', 'subject-responsible', 'service-account'];
|
|
@@ -2222,6 +2794,9 @@ const maxDescription$1 = 300;
|
|
|
2222
2794
|
const maxInstructions = 20_000;
|
|
2223
2795
|
const maxMessage$1 = 2_000;
|
|
2224
2796
|
const maxReason$1 = 500;
|
|
2797
|
+
/** A data trigger applies at most this many filters of its event, each named in lowercase dash-case. */
|
|
2798
|
+
const maxTriggerFilters = 10;
|
|
2799
|
+
const filterName = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2225
2800
|
/** The platform's default file limits (`NhAssistantLimits`). */
|
|
2226
2801
|
const defaultFileLimits = {
|
|
2227
2802
|
maxFileBytes: 20 * 1024 * 1024,
|
|
@@ -2248,8 +2823,20 @@ class NhAssistantMockRunConfiguration {
|
|
|
2248
2823
|
this.skills = new Map();
|
|
2249
2824
|
this.triggers = new Map();
|
|
2250
2825
|
const approvalPolicies = scenario.runCatalog?.approvalPolicies ?? scenario.policies ?? [];
|
|
2826
|
+
this.consultations = new NhAssistantMockConsultations(scenario.consultationsAsData, scenario.agents ?? [], {
|
|
2827
|
+
find: agentId => this.agents.find(agentId),
|
|
2828
|
+
all: () => this.agents.all(),
|
|
2829
|
+
knownSubjectTypes: () => this.agents.knownSubjectTypes(),
|
|
2830
|
+
isConfigured: agent => this.isConfigured(agent),
|
|
2831
|
+
accountabilityOf: agent => agent.source === 'admin' ? this.settings.get(agent.id)?.accountability ?? null : this.codeSettingsOf(agent).accountability,
|
|
2832
|
+
approvalPolicyOf: agent => agent.source === 'admin' ? this.settings.get(agent.id)?.approvalPolicy ?? null : this.codeSettingsOf(agent).approvalPolicy,
|
|
2833
|
+
approvalPolicies: () => this.catalog.approvalPolicies,
|
|
2834
|
+
approvalPolicySummaries: () => this.catalog.approvalPolicySummaries ?? [],
|
|
2835
|
+
agentPolicyNames: () => this.agentPolicyNames
|
|
2836
|
+
}, now);
|
|
2251
2837
|
this.catalog = {
|
|
2252
|
-
|
|
2838
|
+
// A trigger event lists its named filters, none unless the scenario's application registers some.
|
|
2839
|
+
triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
|
|
2253
2840
|
subjectSets: scenario.runCatalog?.subjectSets ?? [],
|
|
2254
2841
|
ruleHandlers: scenario.runCatalog?.ruleHandlers ?? [],
|
|
2255
2842
|
approvalPolicies,
|
|
@@ -2266,7 +2853,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2266
2853
|
agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
|
|
2267
2854
|
divisions: scenario.runCatalog?.divisions ?? false,
|
|
2268
2855
|
// The platform's default file limits, unless the scenario's application chose its own.
|
|
2269
|
-
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
|
|
2856
|
+
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
|
|
2857
|
+
consultationCeiling: this.consultations.applicationCeiling
|
|
2270
2858
|
};
|
|
2271
2859
|
this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
|
|
2272
2860
|
...trigger,
|
|
@@ -2290,13 +2878,25 @@ class NhAssistantMockRunConfiguration {
|
|
|
2290
2878
|
get profileNames() {
|
|
2291
2879
|
return [...this.catalog.profiles];
|
|
2292
2880
|
}
|
|
2881
|
+
/**
|
|
2882
|
+
* The agent's consultations defined as data in effect with their routes in effect, which count for
|
|
2883
|
+
* its route switches.
|
|
2884
|
+
*/
|
|
2885
|
+
consultationsInEffect(agentId) {
|
|
2886
|
+
return this.consultations.inEffect(agentId);
|
|
2887
|
+
}
|
|
2888
|
+
/** The generated consult and request tools of the data consultations in effect. */
|
|
2889
|
+
consultationTools() {
|
|
2890
|
+
return this.consultations.tools();
|
|
2891
|
+
}
|
|
2293
2892
|
/** `GET admin/run-catalog`. */
|
|
2294
2893
|
runCatalog(method) {
|
|
2295
2894
|
return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
|
|
2296
2895
|
}
|
|
2297
2896
|
/**
|
|
2298
|
-
* `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]
|
|
2299
|
-
* `triggers/{triggerId}[/shadow]`; `segments` start after the
|
|
2897
|
+
* `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]`,
|
|
2898
|
+
* `triggers/{triggerId}[/shadow]` and `consultations/{subjectType}`; `segments` start after the
|
|
2899
|
+
* agent id.
|
|
2300
2900
|
*/
|
|
2301
2901
|
handle(method, agentId, segments, body, query) {
|
|
2302
2902
|
const agent = this.agents.find(agentId);
|
|
@@ -2325,6 +2925,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2325
2925
|
}
|
|
2326
2926
|
return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
|
|
2327
2927
|
}
|
|
2928
|
+
if (area === 'consultations' && id !== undefined && segments.length === 2) {
|
|
2929
|
+
return agent ? this.consultations.handle(method, agent, id, body, query) : mockFailure('assistant-agent-not-found');
|
|
2930
|
+
}
|
|
2328
2931
|
return null;
|
|
2329
2932
|
}
|
|
2330
2933
|
configuration(agent) {
|
|
@@ -2345,12 +2948,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
2345
2948
|
dataSkills,
|
|
2346
2949
|
codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
|
|
2347
2950
|
dataTriggers,
|
|
2348
|
-
problems:
|
|
2349
|
-
|
|
2350
|
-
|
|
2951
|
+
problems: [
|
|
2952
|
+
...dataTriggers
|
|
2953
|
+
.filter(trigger => trigger.problem !== null)
|
|
2954
|
+
.map(trigger => ({ agentId: agent.id, kind: 'trigger', id: trigger.triggerId, code: trigger.problem })),
|
|
2955
|
+
...this.consultations.problems(agent)
|
|
2956
|
+
],
|
|
2351
2957
|
source: agent.source,
|
|
2352
2958
|
runSettings: agent.source === 'admin' ? this.settings.get(agent.id) ?? null : null,
|
|
2353
|
-
codeRunSettings: agent.source === 'code' ? this.codeSettingsOf(agent) : null
|
|
2959
|
+
codeRunSettings: agent.source === 'code' ? this.codeSettingsOf(agent) : null,
|
|
2960
|
+
codeConsultations: this.consultations.codeConsultations(agent),
|
|
2961
|
+
dataConsultations: this.consultations.dataConsultations(agent),
|
|
2962
|
+
consultationCeiling: this.consultations.ceilingOf(agent)
|
|
2354
2963
|
};
|
|
2355
2964
|
}
|
|
2356
2965
|
/**
|
|
@@ -2375,8 +2984,13 @@ class NhAssistantMockRunConfiguration {
|
|
|
2375
2984
|
*/
|
|
2376
2985
|
triggerProblem(agent, trigger) {
|
|
2377
2986
|
if (trigger.source === 'event') {
|
|
2378
|
-
const
|
|
2379
|
-
|
|
2987
|
+
const event = this.catalog.triggerEvents.find(item => item.name === trigger.event);
|
|
2988
|
+
if (event?.subjectType && !mockWorksOnCovers(agent, event.subjectType)) {
|
|
2989
|
+
return 'not-worked-on';
|
|
2990
|
+
}
|
|
2991
|
+
// A filter the event no longer offers keeps the trigger from firing.
|
|
2992
|
+
const offered = (event?.filters ?? []).map(filter => filter.name);
|
|
2993
|
+
return (trigger.filters ?? []).some(name => !offered.includes(name)) ? 'unknown-filter' : null;
|
|
2380
2994
|
}
|
|
2381
2995
|
return trigger.subjectSet === null && !mockWorksOnCovers(agent, mockSystemSubjectType) ? 'not-worked-on' : null;
|
|
2382
2996
|
}
|
|
@@ -2392,7 +3006,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2392
3006
|
if (!versionMatches(query, current.version)) {
|
|
2393
3007
|
return mockFailure('assistant-version-conflict');
|
|
2394
3008
|
}
|
|
2395
|
-
if (this.liveSkills(agent.id).length > 0
|
|
3009
|
+
if (this.liveSkills(agent.id).length > 0
|
|
3010
|
+
|| [...this.triggers.values()].some(trigger => this.ownerOf(trigger) === agent.id)
|
|
3011
|
+
|| this.consultations.has(agent.id)) {
|
|
2396
3012
|
return mockFailure('assistant-run-settings-in-use');
|
|
2397
3013
|
}
|
|
2398
3014
|
this.settings.delete(agent.id);
|
|
@@ -2706,9 +3322,20 @@ class NhAssistantMockRunConfiguration {
|
|
|
2706
3322
|
if (skill !== null && !this.skillIds(agent).includes(skill)) {
|
|
2707
3323
|
errors['skill'] = ['not-found'];
|
|
2708
3324
|
}
|
|
3325
|
+
// The filters in the order chosen, each once; only an event trigger takes them.
|
|
3326
|
+
const filters = [...new Set((request.filters ?? []).map(name => (typeof name === 'string' ? name.trim() : '')))];
|
|
3327
|
+
if (filters.length > maxTriggerFilters || filters.some(name => name.length > 64 || !filterName.test(name)) || (schedule && filters.length > 0)) {
|
|
3328
|
+
errors['filters'] = ['invalid'];
|
|
3329
|
+
}
|
|
2709
3330
|
if (Object.keys(errors).length > 0) {
|
|
2710
3331
|
return mockFailure('assistant-validation', errors);
|
|
2711
3332
|
}
|
|
3333
|
+
if (request.source === 'event') {
|
|
3334
|
+
const offered = (this.catalog.triggerEvents.find(item => item.name === text(request.event))?.filters ?? []).map(filter => filter.name);
|
|
3335
|
+
if (filters.some(name => !offered.includes(name))) {
|
|
3336
|
+
return mockFailure('assistant-trigger-filter-not-found', { filters: ['not-found'] });
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
2712
3339
|
if ((request.expectedVersion ?? null) !== (existing?.version ?? null)) {
|
|
2713
3340
|
return mockFailure('assistant-version-conflict');
|
|
2714
3341
|
}
|
|
@@ -2729,7 +3356,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2729
3356
|
changedBy: 'current-user',
|
|
2730
3357
|
reason: text(request.reason),
|
|
2731
3358
|
inEffect: true,
|
|
2732
|
-
problem: null
|
|
3359
|
+
problem: null,
|
|
3360
|
+
filters: schedule ? [] : filters
|
|
2733
3361
|
};
|
|
2734
3362
|
this.triggers.set(triggerId, Object.assign(saved, { [ownerKey]: agent.id }));
|
|
2735
3363
|
return { status: 200, body: withoutOwner(saved) };
|
|
@@ -2903,11 +3531,14 @@ class NhAssistantMockAdmin {
|
|
|
2903
3531
|
hasServer: serverId => this.mcp.hasServer(serverId),
|
|
2904
3532
|
agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
|
|
2905
3533
|
divisionsListed: () => this.runConfiguration.divisionsListed,
|
|
2906
|
-
profileNames: () => this.runConfiguration.profileNames
|
|
3534
|
+
profileNames: () => this.runConfiguration.profileNames,
|
|
3535
|
+
dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
|
|
2907
3536
|
});
|
|
2908
3537
|
this.accounts = new NhAssistantMockAccounts(admin);
|
|
2909
3538
|
this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
|
|
2910
3539
|
find: agentId => this.agents.find(agentId),
|
|
3540
|
+
all: () => this.agents.all(),
|
|
3541
|
+
knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
|
|
2911
3542
|
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
|
|
2912
3543
|
}, now, this.accounts);
|
|
2913
3544
|
this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
|
|
@@ -3068,7 +3699,8 @@ class NhAssistantMockAdmin {
|
|
|
3068
3699
|
/**
|
|
3069
3700
|
* Saves the run configuration the scenario gives agents created in the administration through the
|
|
3070
3701
|
* mock's own endpoints, as an administrator would: the run settings, then each skill and trigger,
|
|
3071
|
-
* put live when the scenario says so
|
|
3702
|
+
* put live when the scenario says so, and then the consultations defined as data of every agent. A
|
|
3703
|
+
* part the mock refuses fails the scenario at once.
|
|
3072
3704
|
*/
|
|
3073
3705
|
presetRunConfigurations(agents) {
|
|
3074
3706
|
const query = new URLSearchParams();
|
|
@@ -3094,11 +3726,21 @@ class NhAssistantMockAdmin {
|
|
|
3094
3726
|
}
|
|
3095
3727
|
}
|
|
3096
3728
|
}
|
|
3729
|
+
for (const agent of agents) {
|
|
3730
|
+
for (const { subjectType, ...consultation } of agent.dataConsultations ?? []) {
|
|
3731
|
+
presetStep(agent.id, `consultation about '${subjectType}'`, this.runConfiguration.handle('PUT', agent.id, ['consultations', subjectType], consultation, query));
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3097
3734
|
}
|
|
3098
|
-
/**
|
|
3735
|
+
/**
|
|
3736
|
+
* The scenario's tools, with the object type they name as the server reports it, the generated tools
|
|
3737
|
+
* of the consultations defined as data in effect, read live as the server reads its catalogs, and
|
|
3738
|
+
* the enabled MCP tools.
|
|
3739
|
+
*/
|
|
3099
3740
|
toolCatalog() {
|
|
3100
3741
|
return [
|
|
3101
3742
|
...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
|
|
3743
|
+
...this.runConfiguration.consultationTools(),
|
|
3102
3744
|
...this.mcp.catalogEntries()
|
|
3103
3745
|
];
|
|
3104
3746
|
}
|
|
@@ -3116,14 +3758,50 @@ function toContext(version) {
|
|
|
3116
3758
|
/** The longest file name a reference takes, as on the server. */
|
|
3117
3759
|
const maxNameLength = 256;
|
|
3118
3760
|
const mediaTypePattern = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
3761
|
+
const zipSignature = [0x50, 0x4b, 0x03, 0x04];
|
|
3762
|
+
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
3763
|
+
const jpegSignature = [0xff, 0xd8, 0xff];
|
|
3764
|
+
/**
|
|
3765
|
+
* The binary formats a text upload may be in disguise, as on the server: an OLE2 compound file (Word,
|
|
3766
|
+
* Excel and PowerPoint 97 to 2003), a ZIP package (also an empty one), a PDF, PNG, JPEG or GIF.
|
|
3767
|
+
*/
|
|
3768
|
+
const binarySignatures = [
|
|
3769
|
+
[0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1],
|
|
3770
|
+
zipSignature,
|
|
3771
|
+
[0x50, 0x4b, 0x05, 0x06],
|
|
3772
|
+
[0x25, 0x50, 0x44, 0x46, 0x2d],
|
|
3773
|
+
pngSignature,
|
|
3774
|
+
jpegSignature,
|
|
3775
|
+
[0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
|
|
3776
|
+
[0x47, 0x49, 0x46, 0x38, 0x39, 0x61]
|
|
3777
|
+
];
|
|
3119
3778
|
/** How files of the platform's types start; other accepted types are the application's to check. */
|
|
3120
3779
|
const signatures = {
|
|
3121
3780
|
'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
3122
|
-
'image/png': head => startsWith(head,
|
|
3123
|
-
'image/jpeg': head => startsWith(head,
|
|
3124
|
-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head,
|
|
3125
|
-
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head,
|
|
3781
|
+
'image/png': head => startsWith(head, pngSignature),
|
|
3782
|
+
'image/jpeg': head => startsWith(head, jpegSignature),
|
|
3783
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head, zipSignature),
|
|
3784
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': head => startsWith(head, zipSignature),
|
|
3785
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': head => startsWith(head, zipSignature)
|
|
3126
3786
|
};
|
|
3787
|
+
/**
|
|
3788
|
+
* Whether the content starts as files of the media type do: the signature of a platform type, and for
|
|
3789
|
+
* text (`text/*`, JSON and XML, also with a `+json` or `+xml` suffix) no binary signature. UTF-16 text
|
|
3790
|
+
* passes: there is no NUL-byte check.
|
|
3791
|
+
*/
|
|
3792
|
+
function matchesSignature(head, mediaType) {
|
|
3793
|
+
if (isTextMediaType(mediaType)) {
|
|
3794
|
+
return !binarySignatures.some(signature => startsWith(head, signature));
|
|
3795
|
+
}
|
|
3796
|
+
return signatures[mediaType]?.(head) ?? true;
|
|
3797
|
+
}
|
|
3798
|
+
function isTextMediaType(mediaType) {
|
|
3799
|
+
return mediaType.startsWith('text/')
|
|
3800
|
+
|| mediaType === 'application/json'
|
|
3801
|
+
|| mediaType === 'application/xml'
|
|
3802
|
+
|| mediaType.endsWith('+json')
|
|
3803
|
+
|| mediaType.endsWith('+xml');
|
|
3804
|
+
}
|
|
3127
3805
|
/**
|
|
3128
3806
|
* The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
|
|
3129
3807
|
* attachments of a message, which a turn opens again as the server's file source would.
|
|
@@ -3163,7 +3841,8 @@ class NhAssistantMockFiles {
|
|
|
3163
3841
|
if (name === null || file.size === 0) {
|
|
3164
3842
|
return mockFailure('assistant-file-upload-invalid');
|
|
3165
3843
|
}
|
|
3166
|
-
|
|
3844
|
+
// A missing or generic type takes the accepted type of the file name's extension, as on the server.
|
|
3845
|
+
const mediaType = normalizeMediaType(_nhAssistantMediaTypeOf({ name, type: file.type }, uploads.acceptedMediaTypes, uploads.mappedExtensions));
|
|
3167
3846
|
if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
|
|
3168
3847
|
return mockFailure('assistant-file-type-not-accepted');
|
|
3169
3848
|
}
|
|
@@ -3171,7 +3850,7 @@ class NhAssistantMockFiles {
|
|
|
3171
3850
|
return mockFailure('assistant-file-too-large');
|
|
3172
3851
|
}
|
|
3173
3852
|
const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
|
|
3174
|
-
if (!(
|
|
3853
|
+
if (!matchesSignature(head, mediaType)) {
|
|
3175
3854
|
return mockFailure('assistant-file-type-not-accepted');
|
|
3176
3855
|
}
|
|
3177
3856
|
const upload = { conversationId, name, mediaType, size: file.size };
|
|
@@ -3653,7 +4332,7 @@ class NhAssistantMockAgentRuns {
|
|
|
3653
4332
|
result: this.answerOf(found.run.id, language),
|
|
3654
4333
|
resultTruncated: this.truncated.has(found.run.id),
|
|
3655
4334
|
summary: found.run.summary,
|
|
3656
|
-
proposals: [],
|
|
4335
|
+
proposals: this.context.runProposals?.(found.run.id) ?? [],
|
|
3657
4336
|
hiddenProposals: 0,
|
|
3658
4337
|
artifacts: [],
|
|
3659
4338
|
canOpenConversation: this.mayView(found.thread, language)
|
|
@@ -3692,7 +4371,9 @@ class NhAssistantMockAgentRuns {
|
|
|
3692
4371
|
return method === 'GET' ? this.lookup(query, language) : { status: 405 };
|
|
3693
4372
|
}
|
|
3694
4373
|
if (segments.length === 3 && method === 'GET') {
|
|
3695
|
-
const listed = segments[2] === 'proposals'
|
|
4374
|
+
const listed = segments[2] === 'proposals'
|
|
4375
|
+
? this.context.threadProposals?.(segments[1]) ?? null
|
|
4376
|
+
: segments[2] === 'consultations' ? this.context.threadConsultations?.(segments[1]) ?? null : null;
|
|
3696
4377
|
if (listed) {
|
|
3697
4378
|
return { status: 200, body: listed };
|
|
3698
4379
|
}
|
|
@@ -4388,7 +5069,7 @@ const maxGroupId = 64;
|
|
|
4388
5069
|
const maxGroups = 100;
|
|
4389
5070
|
const maxName = 100;
|
|
4390
5071
|
const maxDescription = 300;
|
|
4391
|
-
const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
5072
|
+
const guid$1 = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
4392
5073
|
/**
|
|
4393
5074
|
* Groups and boards of the mock API (K15, K16), as the server serves them: the scenario's
|
|
4394
5075
|
* `agentGroups` are the groups in code with their names per language, checked as the server checks
|
|
@@ -4515,7 +5196,7 @@ class NhAssistantMockBoards {
|
|
|
4515
5196
|
}
|
|
4516
5197
|
const groupId = segments[1];
|
|
4517
5198
|
const divisionId = query.get('divisionId');
|
|
4518
|
-
if (divisionId !== null && divisionId !== '' && !guid.test(divisionId)) {
|
|
5199
|
+
if (divisionId !== null && divisionId !== '' && !guid$1.test(divisionId)) {
|
|
4519
5200
|
return mockFailure('assistant-board-scope-invalid');
|
|
4520
5201
|
}
|
|
4521
5202
|
const division = divisionId ? this.division(divisionId, requested) : null;
|
|
@@ -4656,9 +5337,14 @@ const dashCase = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
|
4656
5337
|
const maxReason = 500;
|
|
4657
5338
|
const maxItemsPerPage = 100;
|
|
4658
5339
|
const maxBulkDecisions = 50;
|
|
5340
|
+
const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$/i;
|
|
4659
5341
|
const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
|
|
4660
5342
|
/** The platform's subject type of the whole application. */
|
|
4661
5343
|
const systemType = 'nh-system';
|
|
5344
|
+
/** The person who asked decides a consultation's proposal in their own chat; nobody decides it in the thread. */
|
|
5345
|
+
const askerDecidesCode = 'assistant-consult-asker-decides';
|
|
5346
|
+
/** Someone other than the person who started the work decides it. */
|
|
5347
|
+
const fourEyesCode = 'assistant-four-eyes-required';
|
|
4662
5348
|
/**
|
|
4663
5349
|
* The proposals the scenario lists, which the mock's agents left, for example one a run for the whole
|
|
4664
5350
|
* application placed at a project (`placedBy`). The mock runs no agents: it serves them as the server
|
|
@@ -4668,15 +5354,21 @@ const systemType = 'nh-system';
|
|
|
4668
5354
|
* with the server's validation and statuses. An approved proposal executes at once. A proposal of the
|
|
4669
5355
|
* whole application (`nh-system`) exists only for the approvers of the whole application who pass the
|
|
4670
5356
|
* agent's view policy (`mayDecideApplication`): for anybody else the inbox leaves it out, reading it
|
|
4671
|
-
* answers `404` and deciding it `404 assistant-approval-not-found`.
|
|
5357
|
+
* answers `404` and deciding it `404 assistant-approval-not-found`. A consultation's proposal that the
|
|
5358
|
+
* person who asked decides in their own chat (`asker-decides`) waits inline and is refused here with
|
|
5359
|
+
* `assistant-consult-asker-decides`; one that runs with the agent's own rights refuses its asker with
|
|
5360
|
+
* `assistant-four-eyes-required`.
|
|
4672
5361
|
*/
|
|
4673
5362
|
class NhAssistantMockProposals {
|
|
4674
|
-
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined) {
|
|
5363
|
+
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
|
|
4675
5364
|
this.actorId = actorId;
|
|
4676
5365
|
this.now = now;
|
|
4677
5366
|
this.mayDecideApplication = mayDecideApplication;
|
|
4678
5367
|
this.decided = decided;
|
|
5368
|
+
this.mayWorkIn = mayWorkIn;
|
|
4679
5369
|
this.proposals = new Map();
|
|
5370
|
+
/** When the caller last looked at the inbox, by scope key. */
|
|
5371
|
+
this.visits = new Map();
|
|
4680
5372
|
const created = new Date(now()).getTime();
|
|
4681
5373
|
for (const item of scenario) {
|
|
4682
5374
|
if (this.proposals.has(item.approvalId)) {
|
|
@@ -4685,6 +5377,12 @@ class NhAssistantMockProposals {
|
|
|
4685
5377
|
if (!dashCase.test(item.subjectType)) {
|
|
4686
5378
|
throw new Error(`Mock proposal '${item.approvalId}' names subject type '${item.subjectType}', which is not dash-case.`);
|
|
4687
5379
|
}
|
|
5380
|
+
if (item.consultationRoute && !item.consultationId) {
|
|
5381
|
+
throw new Error(`Mock proposal '${item.approvalId}' names a consultation route without its consultation.`);
|
|
5382
|
+
}
|
|
5383
|
+
const route = item.consultationRoute ?? null;
|
|
5384
|
+
const refusal = consultationRefusal(route, item.initiatorActorId ?? null, actorId());
|
|
5385
|
+
const canDecide = item.canDecide ?? refusal === null;
|
|
4688
5386
|
this.proposals.set(item.approvalId, {
|
|
4689
5387
|
proposal: {
|
|
4690
5388
|
approvalId: item.approvalId,
|
|
@@ -4704,22 +5402,26 @@ class NhAssistantMockProposals {
|
|
|
4704
5402
|
argumentsPreview: '{}',
|
|
4705
5403
|
targets: [],
|
|
4706
5404
|
status: item.status ?? 'pending',
|
|
4707
|
-
mode: 'deferred',
|
|
5405
|
+
mode: route === 'asker-decides' ? 'inline' : 'deferred',
|
|
4708
5406
|
policyId: null,
|
|
4709
|
-
grantSource: 'agent',
|
|
4710
|
-
createdAt: new Date(created).toISOString(),
|
|
4711
|
-
expiresAt: new Date(created + lifetimeMs).toISOString(),
|
|
4712
|
-
initiatorActorId: null,
|
|
5407
|
+
grantSource: item.grantSource ?? (route === 'subject-asker-rights' ? 'delegated' : 'agent'),
|
|
5408
|
+
createdAt: item.createdAt ?? new Date(created).toISOString(),
|
|
5409
|
+
expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
|
|
5410
|
+
initiatorActorId: item.initiatorActorId ?? null,
|
|
4713
5411
|
decidedByActorId: null,
|
|
4714
5412
|
decidedAt: null,
|
|
4715
5413
|
executionResultCode: null,
|
|
4716
5414
|
executedAt: null,
|
|
4717
5415
|
rationale: item.rationale ?? null,
|
|
4718
5416
|
placedBy: item.placedBy ?? null,
|
|
4719
|
-
|
|
5417
|
+
parent: item.parent ?? null,
|
|
5418
|
+
canGiveFeedback: false,
|
|
5419
|
+
consultationId: item.consultationId ?? null,
|
|
5420
|
+
consultationRoute: route
|
|
4720
5421
|
},
|
|
4721
|
-
|
|
4722
|
-
|
|
5422
|
+
divisionId: item.divisionId ?? null,
|
|
5423
|
+
canDecide,
|
|
5424
|
+
reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
|
|
4723
5425
|
});
|
|
4724
5426
|
}
|
|
4725
5427
|
}
|
|
@@ -4728,6 +5430,9 @@ class NhAssistantMockProposals {
|
|
|
4728
5430
|
if (segments.length === 2 && segments[1] === 'inbox') {
|
|
4729
5431
|
return method === 'GET' ? this.inbox(query) : { status: 405 };
|
|
4730
5432
|
}
|
|
5433
|
+
if (segments.length === 3 && segments[1] === 'inbox' && segments[2] === 'visit') {
|
|
5434
|
+
return method === 'POST' ? this.visit(query) : { status: 405 };
|
|
5435
|
+
}
|
|
4731
5436
|
if (segments.length === 2 && segments[1] === 'lookup') {
|
|
4732
5437
|
return method === 'POST' ? this.lookup(body) : { status: 405 };
|
|
4733
5438
|
}
|
|
@@ -4750,6 +5455,27 @@ class NhAssistantMockProposals {
|
|
|
4750
5455
|
.reverse();
|
|
4751
5456
|
return items.length === 0 ? null : items;
|
|
4752
5457
|
}
|
|
5458
|
+
/** The proposals a consultation left per route; routes without proposals are left out. */
|
|
5459
|
+
consultationCounts(consultationId) {
|
|
5460
|
+
const byRoute = {};
|
|
5461
|
+
let total = 0;
|
|
5462
|
+
for (const { proposal } of this.proposals.values()) {
|
|
5463
|
+
if (proposal.consultationId !== consultationId) {
|
|
5464
|
+
continue;
|
|
5465
|
+
}
|
|
5466
|
+
total++;
|
|
5467
|
+
if (proposal.consultationRoute) {
|
|
5468
|
+
byRoute[proposal.consultationRoute] = (byRoute[proposal.consultationRoute] ?? 0) + 1;
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
5471
|
+
return { total, byRoute };
|
|
5472
|
+
}
|
|
5473
|
+
/** The proposals of a run the caller may see, in the order the scenario lists them. */
|
|
5474
|
+
ofRun(runId) {
|
|
5475
|
+
return this.visible()
|
|
5476
|
+
.filter(item => item.proposal.runId === runId)
|
|
5477
|
+
.map(item => item.proposal);
|
|
5478
|
+
}
|
|
4753
5479
|
/** The pending proposals on a subject with the caller's right to decide each, earliest expiry first. */
|
|
4754
5480
|
openOfSubject(subjectType, subjectId) {
|
|
4755
5481
|
return this.visible()
|
|
@@ -4779,19 +5505,49 @@ class NhAssistantMockProposals {
|
|
|
4779
5505
|
}
|
|
4780
5506
|
const agentId = query.get('agentId');
|
|
4781
5507
|
const runId = query.get('runId');
|
|
5508
|
+
const divisionId = query.get('divisionId');
|
|
5509
|
+
if (divisionId !== null && !guid.test(divisionId)) {
|
|
5510
|
+
return { status: 400 };
|
|
5511
|
+
}
|
|
5512
|
+
const grouped = query.get('grouped') === 'true';
|
|
4782
5513
|
const items = this.visible()
|
|
4783
5514
|
.filter(item => item.proposal.status === 'pending' && item.canDecide)
|
|
5515
|
+
.filter(item => divisionId === null || sameGuid(item.divisionId, divisionId))
|
|
4784
5516
|
.map(item => item.proposal)
|
|
4785
5517
|
.filter(proposal => (subjectType === null || proposal.subjectType === subjectType)
|
|
4786
5518
|
&& (agentId === null || proposal.agentId === agentId)
|
|
4787
5519
|
&& (runId === null || proposal.runId === runId))
|
|
4788
5520
|
.sort((left, right) => left.expiresAt.localeCompare(right.expiresAt) || left.approvalId.localeCompare(right.approvalId));
|
|
5521
|
+
const ordered = grouped ? groupedOrder(items) : items;
|
|
5522
|
+
const offset = (page - 1) * itemsPerPage;
|
|
5523
|
+
const pageItems = ordered.slice(offset, offset + itemsPerPage);
|
|
5524
|
+
const scopeKey = scopeKeyOf(divisionId);
|
|
5525
|
+
const lastVisitedAt = this.visits.get(scopeKey) ?? null;
|
|
4789
5526
|
const body = {
|
|
4790
|
-
items:
|
|
4791
|
-
total: items.length
|
|
5527
|
+
items: pageItems,
|
|
5528
|
+
total: items.length,
|
|
5529
|
+
scopeKey,
|
|
5530
|
+
lastVisitedAt,
|
|
5531
|
+
newApprovalIds: pageItems
|
|
5532
|
+
.filter(proposal => lastVisitedAt === null || Date.parse(proposal.createdAt) > Date.parse(lastVisitedAt))
|
|
5533
|
+
.map(proposal => proposal.approvalId),
|
|
5534
|
+
groups: grouped ? groupsOf(ordered, offset, pageItems.length) : null
|
|
4792
5535
|
};
|
|
4793
5536
|
return { status: 200, body };
|
|
4794
5537
|
}
|
|
5538
|
+
/** `POST approvals/inbox/visit`: the whole inbox, or that of a division the caller works in. */
|
|
5539
|
+
visit(query) {
|
|
5540
|
+
const divisionId = query.get('divisionId');
|
|
5541
|
+
if (divisionId !== null && !guid.test(divisionId)) {
|
|
5542
|
+
return { status: 400 };
|
|
5543
|
+
}
|
|
5544
|
+
if (divisionId !== null && !this.mayWorkIn(divisionId)) {
|
|
5545
|
+
return mockFailure('assistant-subject-not-found');
|
|
5546
|
+
}
|
|
5547
|
+
const body = { scopeKey: scopeKeyOf(divisionId), visitedAt: this.now() };
|
|
5548
|
+
this.visits.set(body.scopeKey, body.visitedAt);
|
|
5549
|
+
return { status: 200, body };
|
|
5550
|
+
}
|
|
4795
5551
|
read(approvalId) {
|
|
4796
5552
|
const stored = this.find(approvalId);
|
|
4797
5553
|
return stored ? { status: 200, body: this.withRight(stored) } : mockFailure('assistant-approval-not-found');
|
|
@@ -4879,6 +5635,110 @@ class NhAssistantMockProposals {
|
|
|
4879
5635
|
};
|
|
4880
5636
|
}
|
|
4881
5637
|
}
|
|
5638
|
+
/**
|
|
5639
|
+
* Why nobody, or not the caller, decides a consultation's proposal here: the person who asked decides
|
|
5640
|
+
* an `asker-decides` proposal in their own chat, and four-eyes keeps the asker out of a proposal that
|
|
5641
|
+
* runs with the agent's own rights; `null` when the route does not keep the caller out.
|
|
5642
|
+
*/
|
|
5643
|
+
function consultationRefusal(route, initiatorActorId, actorId) {
|
|
5644
|
+
if (route === 'asker-decides') {
|
|
5645
|
+
return askerDecidesCode;
|
|
5646
|
+
}
|
|
5647
|
+
return route === 'subject-agent-rights' && initiatorActorId === actorId ? fourEyesCode : null;
|
|
5648
|
+
}
|
|
5649
|
+
/** The scope of the inbox's visits: `global`, or the scope key of a division. */
|
|
5650
|
+
function scopeKeyOf(divisionId) {
|
|
5651
|
+
return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
|
|
5652
|
+
}
|
|
5653
|
+
function sameGuid(left, right) {
|
|
5654
|
+
return left !== null && left.replaceAll('-', '').toLowerCase() === right.replaceAll('-', '').toLowerCase();
|
|
5655
|
+
}
|
|
5656
|
+
/** The group a proposal belongs to: its parent, or its own subject without one. */
|
|
5657
|
+
function groupKeyOf(proposal) {
|
|
5658
|
+
return proposal.parent
|
|
5659
|
+
? `${proposal.parent.subjectType}|${proposal.parent.subjectId}`
|
|
5660
|
+
: `${proposal.subjectType}|${proposal.subjectId}`;
|
|
5661
|
+
}
|
|
5662
|
+
function subjectKeyOf(proposal) {
|
|
5663
|
+
return `${groupKeyOf(proposal)}|${proposal.subjectType}|${proposal.subjectId}`;
|
|
5664
|
+
}
|
|
5665
|
+
function firstExpiries(items, key) {
|
|
5666
|
+
const first = new Map();
|
|
5667
|
+
for (const proposal of items) {
|
|
5668
|
+
const current = first.get(key(proposal));
|
|
5669
|
+
if (current === undefined || proposal.expiresAt < current) {
|
|
5670
|
+
first.set(key(proposal), proposal.expiresAt);
|
|
5671
|
+
}
|
|
5672
|
+
}
|
|
5673
|
+
return first;
|
|
5674
|
+
}
|
|
5675
|
+
/**
|
|
5676
|
+
* The order of the grouped inbox, as the server sorts: groups by their first expiry, a group's own
|
|
5677
|
+
* subject first, then its other subjects by their first expiry, one run's proposals together, then by
|
|
5678
|
+
* expiry.
|
|
5679
|
+
*/
|
|
5680
|
+
function groupedOrder(items) {
|
|
5681
|
+
const groups = firstExpiries(items, groupKeyOf);
|
|
5682
|
+
const subjects = firstExpiries(items, subjectKeyOf);
|
|
5683
|
+
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
|
5684
|
+
return [...items].sort((left, right) => compare(groups.get(groupKeyOf(left)), groups.get(groupKeyOf(right)))
|
|
5685
|
+
|| compare(groupKeyOf(left), groupKeyOf(right))
|
|
5686
|
+
|| (left.parent ? 1 : 0) - (right.parent ? 1 : 0)
|
|
5687
|
+
|| compare(subjects.get(subjectKeyOf(left)), subjects.get(subjectKeyOf(right)))
|
|
5688
|
+
|| compare(subjectKeyOf(left), subjectKeyOf(right))
|
|
5689
|
+
|| (left.runId === null ? 1 : 0) - (right.runId === null ? 1 : 0)
|
|
5690
|
+
|| compare(left.runId ?? '', right.runId ?? '')
|
|
5691
|
+
|| compare(left.expiresAt, right.expiresAt)
|
|
5692
|
+
|| compare(left.approvalId, right.approvalId));
|
|
5693
|
+
}
|
|
5694
|
+
/** The groups of the page `ordered[offset, offset + count)`, with their totals and continuation. */
|
|
5695
|
+
function groupsOf(ordered, offset, count) {
|
|
5696
|
+
const page = ordered.slice(offset, offset + count);
|
|
5697
|
+
const previous = offset > 0 ? ordered[offset - 1] : undefined;
|
|
5698
|
+
const next = ordered[offset + count];
|
|
5699
|
+
const groups = [];
|
|
5700
|
+
for (let index = 0; index < page.length;) {
|
|
5701
|
+
const key = groupKeyOf(page[index]);
|
|
5702
|
+
let end = index;
|
|
5703
|
+
while (end < page.length && groupKeyOf(page[end]) === key) {
|
|
5704
|
+
end++;
|
|
5705
|
+
}
|
|
5706
|
+
const members = page.slice(index, end);
|
|
5707
|
+
const own = members.find(proposal => !proposal.parent);
|
|
5708
|
+
const parent = members.find(proposal => proposal.parent)?.parent ?? null;
|
|
5709
|
+
const subjects = [];
|
|
5710
|
+
for (let at = 0; at < members.length;) {
|
|
5711
|
+
const subjectKey = subjectKeyOf(members[at]);
|
|
5712
|
+
let stop = at;
|
|
5713
|
+
while (stop < members.length && subjectKeyOf(members[stop]) === subjectKey) {
|
|
5714
|
+
stop++;
|
|
5715
|
+
}
|
|
5716
|
+
subjects.push({
|
|
5717
|
+
subjectType: members[at].subjectType,
|
|
5718
|
+
subjectId: members[at].subjectId,
|
|
5719
|
+
count: stop - at,
|
|
5720
|
+
total: ordered.filter(proposal => subjectKeyOf(proposal) === subjectKey).length,
|
|
5721
|
+
continuesFromPreviousPage: index === 0 && at === 0 && previous !== undefined && subjectKeyOf(previous) === subjectKey,
|
|
5722
|
+
continuesOnNextPage: end === page.length && stop === members.length && next !== undefined && subjectKeyOf(next) === subjectKey
|
|
5723
|
+
});
|
|
5724
|
+
at = stop;
|
|
5725
|
+
}
|
|
5726
|
+
groups.push({
|
|
5727
|
+
subjectType: own?.subjectType ?? parent.subjectType,
|
|
5728
|
+
subjectId: own?.subjectId ?? parent.subjectId,
|
|
5729
|
+
displayName: own ? own.subjectDisplayName : parent.displayName,
|
|
5730
|
+
subjectTypeName: own ? own.subjectTypeName ?? null : parent.subjectTypeName ?? null,
|
|
5731
|
+
subjectLink: own ? own.subjectLink ?? null : parent.subjectLink ?? null,
|
|
5732
|
+
count: members.length,
|
|
5733
|
+
total: ordered.filter(proposal => groupKeyOf(proposal) === key).length,
|
|
5734
|
+
continuesFromPreviousPage: index === 0 && previous !== undefined && groupKeyOf(previous) === key,
|
|
5735
|
+
continuesOnNextPage: end === page.length && next !== undefined && groupKeyOf(next) === key,
|
|
5736
|
+
subjects
|
|
5737
|
+
});
|
|
5738
|
+
index = end;
|
|
5739
|
+
}
|
|
5740
|
+
return groups;
|
|
5741
|
+
}
|
|
4882
5742
|
function validDecision(request) {
|
|
4883
5743
|
return !!request
|
|
4884
5744
|
&& (request.decision === 'approve' || request.decision === 'reject')
|
|
@@ -4920,6 +5780,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
|
|
|
4920
5780
|
const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
|
|
4921
5781
|
const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
|
|
4922
5782
|
const defaultUser = { actorId: 'mock-user', displayName: 'You' };
|
|
5783
|
+
/** When the scenario's consultations started, when they name no time. */
|
|
5784
|
+
const consultationStart = new Date().toISOString();
|
|
4923
5785
|
class RunStopped extends Error {
|
|
4924
5786
|
}
|
|
4925
5787
|
/**
|
|
@@ -4946,12 +5808,14 @@ class NhAssistantMockBackend {
|
|
|
4946
5808
|
runId: proposal.runId,
|
|
4947
5809
|
subjectType: proposal.subjectType,
|
|
4948
5810
|
subjectId: proposal.subjectId
|
|
4949
|
-
}, proposal.agentId));
|
|
5811
|
+
}, proposal.agentId), (divisionId) => this.subjects.describe(mockSystemSubjectType, divisionSubjectId({ id: divisionId, name: '' }), null, this.admin.administers) != null);
|
|
4950
5812
|
this.subjects = new NhAssistantMockSubjects(this.scenario.subjectTypes, this.scenario.divisions, (type, id) => this.proposals.openOfSubject(type, id));
|
|
4951
5813
|
this.agentRuns = new NhAssistantMockAgentRuns(this.scenario, this.subjects, {
|
|
4952
5814
|
agents: () => this.admin.allAgents(),
|
|
4953
5815
|
administers: () => this.admin.administers,
|
|
4954
5816
|
threadProposals: conversationId => this.proposals.ofThread(conversationId),
|
|
5817
|
+
threadConsultations: conversationId => this.consultationsOf(conversationId),
|
|
5818
|
+
runProposals: runId => this.proposals.ofRun(runId),
|
|
4955
5819
|
workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
|
|
4956
5820
|
}, () => new Date().toISOString());
|
|
4957
5821
|
this.boards = new NhAssistantMockBoards(this.scenario, this.subjects, this.agentRuns, {
|
|
@@ -5071,6 +5935,37 @@ class NhAssistantMockBackend {
|
|
|
5071
5935
|
const turn = this.findTurn(text, conversation.agentId, null);
|
|
5072
5936
|
await this.playSteps(conversationId, assistantMessageId, turn?.steps ?? [], emit);
|
|
5073
5937
|
}
|
|
5938
|
+
/**
|
|
5939
|
+
* Posts a message of the application into a conversation, as `INhAssistantConversationMessages`
|
|
5940
|
+
* does on the server: it gets the next position in the conversation and reaches the people of the
|
|
5941
|
+
* conversation as a live update. `trusted` marks an instruction for the assistant, as `Trusted`
|
|
5942
|
+
* does on the server. Returns the stored message, or `null` for an unknown or archived conversation.
|
|
5943
|
+
*/
|
|
5944
|
+
postApplicationMessage(conversationId, text, details = {}) {
|
|
5945
|
+
const conversation = this.conversations.get(conversationId);
|
|
5946
|
+
if (!conversation || conversation.status === 'archived') {
|
|
5947
|
+
return null;
|
|
5948
|
+
}
|
|
5949
|
+
const message = {
|
|
5950
|
+
id: this.nextId(),
|
|
5951
|
+
role: 'application',
|
|
5952
|
+
createdAt: new Date().toISOString(),
|
|
5953
|
+
parts: [{ type: 'text', text }],
|
|
5954
|
+
authorActorId: null,
|
|
5955
|
+
authorKind: 'application',
|
|
5956
|
+
application: {
|
|
5957
|
+
title: details.title ?? null,
|
|
5958
|
+
senderName: details.senderName ?? null,
|
|
5959
|
+
link: details.link ?? null,
|
|
5960
|
+
trusted: details.trusted ?? false
|
|
5961
|
+
}
|
|
5962
|
+
};
|
|
5963
|
+
this.update(conversationId, current => ({ ...current, messages: [...current.messages, message] }));
|
|
5964
|
+
const stored = this.conversations.get(conversationId).messages.find(item => item.id === message.id);
|
|
5965
|
+
this.emitLive({ kind: 'event', data: { conversationId, actorId: 'application', type: 'message.created', data: stored } });
|
|
5966
|
+
this.emitChanged(conversationId);
|
|
5967
|
+
return stored;
|
|
5968
|
+
}
|
|
5074
5969
|
/** Switches the simulated `NewHeap:AI:Assistant:Enabled` flag. */
|
|
5075
5970
|
setEnabled(enabled) {
|
|
5076
5971
|
this.enabledState.set(enabled);
|
|
@@ -5319,6 +6214,12 @@ class NhAssistantMockBackend {
|
|
|
5319
6214
|
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
5320
6215
|
});
|
|
5321
6216
|
}
|
|
6217
|
+
/**
|
|
6218
|
+
* `POST conversations/{id}/approvals/{approvalId}/decide`: approves or rejects the approval the
|
|
6219
|
+
* turn waits for, or leaves a consulted agent's proposal with a `leaveRoute` for the subject's
|
|
6220
|
+
* approvers (`leave`). Leaving closes it here, names the proposal that now waits at the subject
|
|
6221
|
+
* (`consultation.movedToApprovalId`) and resumes the turn with the step's `left` steps.
|
|
6222
|
+
*/
|
|
5322
6223
|
decide(conversation, approvalId, request) {
|
|
5323
6224
|
const pending = this.pendingApprovals.get(conversation.id);
|
|
5324
6225
|
if (!pending || pending.approval.approvalId !== approvalId) {
|
|
@@ -5328,16 +6229,29 @@ class NhAssistantMockBackend {
|
|
|
5328
6229
|
if (decider && decider !== this.currentUser.actorId) {
|
|
5329
6230
|
return this.error('assistant-approval-forbidden');
|
|
5330
6231
|
}
|
|
5331
|
-
|
|
6232
|
+
const decision = request?.decision;
|
|
6233
|
+
if (decision !== 'approve' && decision !== 'reject' && decision !== 'leave') {
|
|
6234
|
+
return this.error('assistant-approval-decision-invalid');
|
|
6235
|
+
}
|
|
6236
|
+
if (decision === 'leave' && !pending.approval.consultation) {
|
|
6237
|
+
return this.error('assistant-approval-decision-invalid');
|
|
6238
|
+
}
|
|
6239
|
+
if (decision === 'leave' && !pending.approval.consultation?.leaveRoute) {
|
|
6240
|
+
return this.error('assistant-consult-proposal-refused');
|
|
6241
|
+
}
|
|
6242
|
+
if (request.expectedProposalHash !== pending.approval.proposalHash) {
|
|
5332
6243
|
return this.empty(409);
|
|
5333
6244
|
}
|
|
5334
6245
|
if (Date.parse(pending.approval.expiresAt) <= Date.now()) {
|
|
5335
6246
|
return this.empty(409);
|
|
5336
6247
|
}
|
|
5337
6248
|
this.pendingApprovals.delete(conversation.id);
|
|
5338
|
-
const decision = request.decision;
|
|
5339
6249
|
const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
|
|
5340
6250
|
this.update(conversation.id, current => applyNhAssistantApprovalDecision(current, approvalId, decision));
|
|
6251
|
+
if (decision === 'leave') {
|
|
6252
|
+
const movedToApprovalId = this.nextId();
|
|
6253
|
+
this.update(conversation.id, current => withMovedApproval(current, approvalId, movedToApprovalId));
|
|
6254
|
+
}
|
|
5341
6255
|
return this.stream(conversation.id, async (emit) => {
|
|
5342
6256
|
await emit({
|
|
5343
6257
|
type: 'turn.started',
|
|
@@ -5350,6 +6264,14 @@ class NhAssistantMockBackend {
|
|
|
5350
6264
|
});
|
|
5351
6265
|
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.approved, emit);
|
|
5352
6266
|
}
|
|
6267
|
+
else if (decision === 'leave') {
|
|
6268
|
+
// The consult call ends: its proposal now waits for the subject's approvers.
|
|
6269
|
+
await emit({
|
|
6270
|
+
type: 'tool.completed',
|
|
6271
|
+
data: { invocationId: pending.invocationId, status: 'succeeded', resultCode: 'assistant-consult-proposal-moved', resultPreview: null }
|
|
6272
|
+
});
|
|
6273
|
+
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.left ?? [], emit);
|
|
6274
|
+
}
|
|
5353
6275
|
else {
|
|
5354
6276
|
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.rejected, emit);
|
|
5355
6277
|
}
|
|
@@ -5459,7 +6381,21 @@ class NhAssistantMockBackend {
|
|
|
5459
6381
|
argumentsPreview: step.argumentsPreview,
|
|
5460
6382
|
targets: step.targets,
|
|
5461
6383
|
expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 300) * 1000).toISOString(),
|
|
5462
|
-
status: 'pending'
|
|
6384
|
+
status: 'pending',
|
|
6385
|
+
...(step.consultation
|
|
6386
|
+
? {
|
|
6387
|
+
consultation: {
|
|
6388
|
+
consultationId: step.consultation.consultationId ?? this.nextId(),
|
|
6389
|
+
agentId: step.consultation.agentId,
|
|
6390
|
+
subjectType: step.consultation.subjectType,
|
|
6391
|
+
subjectId: step.consultation.subjectId,
|
|
6392
|
+
subjectDisplayName: step.consultation.subjectDisplayName ?? null,
|
|
6393
|
+
leaveRoute: step.consultation.leaveRoute ?? null,
|
|
6394
|
+
answer: step.consultation.answer ?? null,
|
|
6395
|
+
movedToApprovalId: null
|
|
6396
|
+
}
|
|
6397
|
+
}
|
|
6398
|
+
: {})
|
|
5463
6399
|
};
|
|
5464
6400
|
this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
|
|
5465
6401
|
await emit({ type: 'approval.required', data: approval });
|
|
@@ -5715,6 +6651,40 @@ class NhAssistantMockBackend {
|
|
|
5715
6651
|
const policy = agent?.viewPolicy ?? agent?.requiredPolicy ?? null;
|
|
5716
6652
|
return policy === null || (this.scenario.callerPolicies?.includes(policy) ?? true);
|
|
5717
6653
|
}
|
|
6654
|
+
/**
|
|
6655
|
+
* The scenario's consultations of a thread's agent, newest first, each with the proposals of the
|
|
6656
|
+
* scenario that name it, counted per route; `null` when the scenario lists none there.
|
|
6657
|
+
*/
|
|
6658
|
+
consultationsOf(conversationId) {
|
|
6659
|
+
const listed = (this.scenario.consultations ?? []).filter(item => item.conversationId === conversationId);
|
|
6660
|
+
if (listed.length === 0) {
|
|
6661
|
+
return null;
|
|
6662
|
+
}
|
|
6663
|
+
const items = listed
|
|
6664
|
+
.map(item => {
|
|
6665
|
+
const proposals = this.proposals.consultationCounts(item.id);
|
|
6666
|
+
const createdAt = item.createdAt ?? consultationStart;
|
|
6667
|
+
const status = item.status ?? 'completed';
|
|
6668
|
+
return {
|
|
6669
|
+
id: item.id,
|
|
6670
|
+
askerActorId: item.askerActorId,
|
|
6671
|
+
askerDisplayName: item.askerDisplayName ?? null,
|
|
6672
|
+
askingAgentId: item.askingAgentId ?? null,
|
|
6673
|
+
depth: 1,
|
|
6674
|
+
status,
|
|
6675
|
+
failureCode: status === 'failed' ? 'assistant-consult-failed' : null,
|
|
6676
|
+
toolCalls: 0,
|
|
6677
|
+
inputTokens: 0,
|
|
6678
|
+
outputTokens: 0,
|
|
6679
|
+
proposalCount: proposals.total,
|
|
6680
|
+
createdAt,
|
|
6681
|
+
endedAt: status === 'running' ? null : createdAt,
|
|
6682
|
+
proposalsByRoute: proposals.byRoute
|
|
6683
|
+
};
|
|
6684
|
+
})
|
|
6685
|
+
.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
|
6686
|
+
return { items, total: items.length };
|
|
6687
|
+
}
|
|
5718
6688
|
nextId() {
|
|
5719
6689
|
this.sequence++;
|
|
5720
6690
|
const suffix = this.sequence.toString(16).padStart(12, '0');
|
|
@@ -5756,6 +6726,19 @@ function languageOf(headers) {
|
|
|
5756
6726
|
function delay(milliseconds) {
|
|
5757
6727
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
5758
6728
|
}
|
|
6729
|
+
/**
|
|
6730
|
+
* A consulted agent's proposal the person left for the subject's approvers, as the server reads it
|
|
6731
|
+
* afterwards: closed here and naming the proposal that waits at the subject.
|
|
6732
|
+
*/
|
|
6733
|
+
function withMovedApproval(conversation, approvalId, movedToApprovalId) {
|
|
6734
|
+
const moved = (part) => part.type === 'approval' && part.approvalId === approvalId && part.consultation
|
|
6735
|
+
? { ...part, status: 'rejected', consultation: { ...part.consultation, leaveRoute: null, movedToApprovalId } }
|
|
6736
|
+
: part;
|
|
6737
|
+
return {
|
|
6738
|
+
...conversation,
|
|
6739
|
+
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(moved) }))
|
|
6740
|
+
};
|
|
6741
|
+
}
|
|
5759
6742
|
/** Like the server: a new message starts in an idle conversation or after a failed turn. */
|
|
5760
6743
|
function acceptsMessage(status) {
|
|
5761
6744
|
return status === 'idle' || status === 'failed';
|