@newheap/platform-ai-chat 0.17.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 +11 -1
- package/admin/index.d.ts +92 -6
- package/agent-runs/index.d.ts +43 -7
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +837 -155
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs +120 -35
- package/fesm2022/newheap-platform-ai-chat-agent-runs.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +870 -48
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +1018 -184
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +236 -12
- package/i18n/nl.json +236 -12
- package/index.d.ts +439 -31
- package/package.json +1 -1
- package/testing/index.d.ts +130 -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
|
/**
|
|
@@ -83,6 +83,7 @@ const statusTable = {
|
|
|
83
83
|
'assistant-board-not-found',
|
|
84
84
|
'assistant-artifact-not-found',
|
|
85
85
|
'assistant-trigger-not-found',
|
|
86
|
+
'assistant-consultation-not-found',
|
|
86
87
|
'assistant-not-found',
|
|
87
88
|
'assistant-context-not-found',
|
|
88
89
|
'assistant-mcp-server-not-found',
|
|
@@ -107,6 +108,12 @@ const statusTable = {
|
|
|
107
108
|
'assistant-moved-to-shadow',
|
|
108
109
|
'assistant-shadow-variant',
|
|
109
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',
|
|
110
117
|
'assistant-skill-in-code',
|
|
111
118
|
'assistant-trigger-in-code',
|
|
112
119
|
'assistant-skill-in-use',
|
|
@@ -283,7 +290,7 @@ class NhAssistantMockAccounts {
|
|
|
283
290
|
|
|
284
291
|
/** The platform's type of the whole application. */
|
|
285
292
|
const mockSystemSubjectType = 'nh-system';
|
|
286
|
-
const subjectTypePattern = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
293
|
+
const subjectTypePattern$1 = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
287
294
|
const maxSubjectTypes = 20;
|
|
288
295
|
const worksOnFields = ['worksOnSubjectTypes', 'worksOnAllSubjects', 'worksOnApplication', 'perDivision', 'runPolicy', 'viewPolicy'];
|
|
289
296
|
/** An agent that does not say what it works on: every object type, not the application, app-wide. */
|
|
@@ -336,10 +343,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
|
|
|
336
343
|
if (types.length > maxSubjectTypes) {
|
|
337
344
|
add('worksOnSubjectTypes', 'too-long');
|
|
338
345
|
}
|
|
339
|
-
if (types.some(type => !subjectTypePattern.test(type))) {
|
|
346
|
+
if (types.some(type => !subjectTypePattern$1.test(type))) {
|
|
340
347
|
add('worksOnSubjectTypes', 'invalid');
|
|
341
348
|
}
|
|
342
|
-
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)))) {
|
|
343
350
|
add('worksOnSubjectTypes', 'not-found');
|
|
344
351
|
}
|
|
345
352
|
if (worksOn.perDivision && !divisions) {
|
|
@@ -364,6 +371,12 @@ function mockWorksOnCovers(agent, subjectType) {
|
|
|
364
371
|
return agent.worksOnAllSubjects !== false || (agent.worksOnSubjectTypes ?? []).includes(subjectType);
|
|
365
372
|
}
|
|
366
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
|
+
];
|
|
367
380
|
/** The profile of an agent that did not choose one, as the application's chat profile. */
|
|
368
381
|
const mockChatProfile = 'chat';
|
|
369
382
|
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
@@ -384,7 +397,7 @@ class NhAssistantMockAdminAgents {
|
|
|
384
397
|
const agents = admin.agents
|
|
385
398
|
?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
|
|
386
399
|
// How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
|
|
387
|
-
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) {
|
|
388
401
|
const agentShadow = shadowOf !== undefined || shadow === true;
|
|
389
402
|
const initial = this.toAgent(input, source, 1, false);
|
|
390
403
|
this.agents.set(input.id, {
|
|
@@ -415,7 +428,11 @@ class NhAssistantMockAdminAgents {
|
|
|
415
428
|
shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, shadowOf !== undefined)
|
|
416
429
|
}))
|
|
417
430
|
},
|
|
418
|
-
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)
|
|
419
436
|
});
|
|
420
437
|
}
|
|
421
438
|
}
|
|
@@ -456,7 +473,10 @@ class NhAssistantMockAdminAgents {
|
|
|
456
473
|
}
|
|
457
474
|
return result;
|
|
458
475
|
}
|
|
459
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* `admin/agents`, `admin/agents/{id}` and their `reset`, `state`, `shadow`, `service-account`,
|
|
478
|
+
* `consultation-routes` and `kill-switch`.
|
|
479
|
+
*/
|
|
460
480
|
agentsEndpoint(method, id, action, body, length) {
|
|
461
481
|
if (id === undefined) {
|
|
462
482
|
if (method === 'GET') {
|
|
@@ -474,7 +494,7 @@ class NhAssistantMockAdminAgents {
|
|
|
474
494
|
return mockFailure('assistant-agent-exists');
|
|
475
495
|
}
|
|
476
496
|
const agent = this.toAgent(input, 'admin', 1, false);
|
|
477
|
-
this.agents.set(agent.id, { agent, codeDefinition: null });
|
|
497
|
+
this.agents.set(agent.id, { agent, codeDefinition: null, consultations: noSwitches(null) });
|
|
478
498
|
return { status: 201, body: agent };
|
|
479
499
|
}
|
|
480
500
|
const stored = this.agents.get(id);
|
|
@@ -497,6 +517,9 @@ class NhAssistantMockAdminAgents {
|
|
|
497
517
|
if (length === 4 && action === 'service-account' && method === 'POST') {
|
|
498
518
|
return this.serviceAccountEndpoint(stored, body);
|
|
499
519
|
}
|
|
520
|
+
if (length === 4 && action === 'consultation-routes' && method === 'POST') {
|
|
521
|
+
return this.consultationRoutesEndpoint(stored, body);
|
|
522
|
+
}
|
|
500
523
|
if (length === 4 && action === 'kill-switch' && method === 'POST') {
|
|
501
524
|
const engaged = body?.engaged;
|
|
502
525
|
if (typeof engaged !== 'boolean') {
|
|
@@ -504,7 +527,7 @@ class NhAssistantMockAdminAgents {
|
|
|
504
527
|
}
|
|
505
528
|
// Releasing never switches on an agent that was off, as on the server: one that was off when
|
|
506
529
|
// it stopped goes back off, and one that is off stays off.
|
|
507
|
-
const released = stored.agent.runState === 'off' || stored.offBeforeStop === true ? 'off' : 'active';
|
|
530
|
+
const released = stored.agent.runState === 'off' || stored.agent.offBeforeStop === true ? 'off' : 'active';
|
|
508
531
|
const changed = this.agentStateEndpoint(stored, 'POST', { state: engaged ? 'stopped' : released });
|
|
509
532
|
return changed.status === 200
|
|
510
533
|
? {
|
|
@@ -765,7 +788,7 @@ class NhAssistantMockAdminAgents {
|
|
|
765
788
|
skills: (stored.agent.skills ?? []).map(skill => ({ ...skill, shadow: withAgentShadow(skill.shadow, shadow) }))
|
|
766
789
|
};
|
|
767
790
|
}
|
|
768
|
-
return { status: 200, body: toAgentState(stored
|
|
791
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
769
792
|
}
|
|
770
793
|
/**
|
|
771
794
|
* `POST admin/agents/{id}/service-account`: the technical account of an agent that answers to
|
|
@@ -802,7 +825,96 @@ class NhAssistantMockAdminAgents {
|
|
|
802
825
|
}
|
|
803
826
|
};
|
|
804
827
|
}
|
|
805
|
-
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) };
|
|
806
918
|
}
|
|
807
919
|
/**
|
|
808
920
|
* `GET` and `POST admin/agents/{id}/state`. The mock runs no agents on their own, so a pause
|
|
@@ -811,7 +923,7 @@ class NhAssistantMockAdminAgents {
|
|
|
811
923
|
*/
|
|
812
924
|
agentStateEndpoint(stored, method, body) {
|
|
813
925
|
if (method === 'GET') {
|
|
814
|
-
return { status: 200, body: toAgentState(stored
|
|
926
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
815
927
|
}
|
|
816
928
|
if (method !== 'POST') {
|
|
817
929
|
return { status: 405 };
|
|
@@ -830,21 +942,21 @@ class NhAssistantMockAdminAgents {
|
|
|
830
942
|
if (stored.agent.runState === 'configured-off') {
|
|
831
943
|
return request.state === 'active'
|
|
832
944
|
? mockFailure('assistant-agent-configured-off')
|
|
833
|
-
: { status: 200, body: toAgentState(stored
|
|
945
|
+
: { status: 200, body: this.toAgentState(stored) };
|
|
834
946
|
}
|
|
835
947
|
if (stored.agent.runState !== request.state) {
|
|
836
|
-
// A stop remembers whether the agent was off, for the kill switch; every other change forgets it.
|
|
837
|
-
stored.offBeforeStop = request.state === 'stopped' && stored.agent.runState === 'off';
|
|
838
948
|
stored.agent = {
|
|
839
949
|
...stored.agent,
|
|
840
950
|
isEnabled: request.state !== 'stopped',
|
|
841
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',
|
|
842
954
|
stateChangedAt: this.now(),
|
|
843
955
|
stateChangedBy: 'current-user',
|
|
844
956
|
stateReason: reason
|
|
845
957
|
};
|
|
846
958
|
}
|
|
847
|
-
return { status: 200, body: toAgentState(stored
|
|
959
|
+
return { status: 200, body: this.toAgentState(stored) };
|
|
848
960
|
}
|
|
849
961
|
/**
|
|
850
962
|
* The contract failure for an invalid agent, or null when the agent is valid. What an agent created
|
|
@@ -925,6 +1037,7 @@ class NhAssistantMockAdminAgents {
|
|
|
925
1037
|
shadowOf: previous?.shadowOf ?? null,
|
|
926
1038
|
shadow: previous?.shadow ?? shadowState(false, false, null, false),
|
|
927
1039
|
serviceAccount: previous?.serviceAccount ?? null,
|
|
1040
|
+
offBeforeStop: previous?.offBeforeStop ?? false,
|
|
928
1041
|
// Code decides for its agents; an agent created here keeps its choice when it is omitted.
|
|
929
1042
|
offeredInChat: source === 'code' && previous
|
|
930
1043
|
? previous.offeredInChat ?? true
|
|
@@ -960,7 +1073,8 @@ function codeAgentFromSummary(agent) {
|
|
|
960
1073
|
autonomy: agent.canMutate ? 'execute' : 'explain'
|
|
961
1074
|
};
|
|
962
1075
|
}
|
|
963
|
-
function toAgentState(
|
|
1076
|
+
function toAgentState(stored) {
|
|
1077
|
+
const agent = stored.agent;
|
|
964
1078
|
return {
|
|
965
1079
|
agentId: agent.id,
|
|
966
1080
|
runState: agent.runState,
|
|
@@ -974,9 +1088,15 @@ function toAgentState(agent) {
|
|
|
974
1088
|
skills: agent.skills ?? [],
|
|
975
1089
|
shadowOf: agent.shadowOf ?? null,
|
|
976
1090
|
shadow: agent.shadow ?? shadowState(false, false, null, false),
|
|
977
|
-
serviceAccount: agent.serviceAccount ?? null
|
|
1091
|
+
serviceAccount: agent.serviceAccount ?? null,
|
|
1092
|
+
offBeforeStop: agent.runState === 'stopped' && agent.offBeforeStop === true,
|
|
1093
|
+
consultationRoutes: null
|
|
978
1094
|
};
|
|
979
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
|
+
}
|
|
980
1100
|
function shadowState(shadow, configured, override, locked) {
|
|
981
1101
|
return { shadow, configured, override, locked, changedAt: null, changedBy: null, reason: null };
|
|
982
1102
|
}
|
|
@@ -1034,7 +1154,7 @@ const maxReaders = 16;
|
|
|
1034
1154
|
const maxReaderMediaTypes = 16;
|
|
1035
1155
|
const maxReaderToolName = 128;
|
|
1036
1156
|
const readerPriority = { min: -1000, max: 1000 };
|
|
1037
|
-
const classifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
1157
|
+
const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
1038
1158
|
const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
1039
1159
|
const controlCharacters = /[\u0000-\u001f\u007f]/;
|
|
1040
1160
|
/**
|
|
@@ -2107,7 +2227,7 @@ function checkReaders(input, stored, add) {
|
|
|
2107
2227
|
if (classification === null || classification === undefined) {
|
|
2108
2228
|
add(field('maxClassification'), 'required');
|
|
2109
2229
|
}
|
|
2110
|
-
else if (!classifications.includes(classification)) {
|
|
2230
|
+
else if (!classifications$1.includes(classification)) {
|
|
2111
2231
|
add(field('maxClassification'), 'invalid');
|
|
2112
2232
|
}
|
|
2113
2233
|
const priority = reader?.priority ?? 0;
|
|
@@ -2215,6 +2335,457 @@ function kebab(value) {
|
|
|
2215
2335
|
.toLowerCase();
|
|
2216
2336
|
}
|
|
2217
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
|
+
|
|
2218
2789
|
const dashCase$4 = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
2219
2790
|
const dataClassifications = ['public', 'internal', 'confidential', 'restricted'];
|
|
2220
2791
|
const accountabilities = ['agent', 'subject-responsible', 'service-account'];
|
|
@@ -2252,6 +2823,17 @@ class NhAssistantMockRunConfiguration {
|
|
|
2252
2823
|
this.skills = new Map();
|
|
2253
2824
|
this.triggers = new Map();
|
|
2254
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);
|
|
2255
2837
|
this.catalog = {
|
|
2256
2838
|
// A trigger event lists its named filters, none unless the scenario's application registers some.
|
|
2257
2839
|
triggerEvents: (scenario.runCatalog?.triggerEvents ?? []).map(event => ({ ...event, filters: event.filters ?? [] })),
|
|
@@ -2271,7 +2853,8 @@ class NhAssistantMockRunConfiguration {
|
|
|
2271
2853
|
agentPolicies: scenario.runCatalog?.agentPolicies ?? [],
|
|
2272
2854
|
divisions: scenario.runCatalog?.divisions ?? false,
|
|
2273
2855
|
// The platform's default file limits, unless the scenario's application chose its own.
|
|
2274
|
-
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits
|
|
2856
|
+
fileLimits: scenario.runCatalog?.fileLimits ?? defaultFileLimits,
|
|
2857
|
+
consultationCeiling: this.consultations.applicationCeiling
|
|
2275
2858
|
};
|
|
2276
2859
|
this.codeTriggers = (scenario.codeTriggers ?? []).map(trigger => ({
|
|
2277
2860
|
...trigger,
|
|
@@ -2295,13 +2878,25 @@ class NhAssistantMockRunConfiguration {
|
|
|
2295
2878
|
get profileNames() {
|
|
2296
2879
|
return [...this.catalog.profiles];
|
|
2297
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
|
+
}
|
|
2298
2892
|
/** `GET admin/run-catalog`. */
|
|
2299
2893
|
runCatalog(method) {
|
|
2300
2894
|
return method === 'GET' ? { status: 200, body: this.catalog } : { status: 405 };
|
|
2301
2895
|
}
|
|
2302
2896
|
/**
|
|
2303
|
-
* `admin/agents/{id}/configuration`, `run-settings`, `skills/{skillId}[/...]
|
|
2304
|
-
* `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.
|
|
2305
2900
|
*/
|
|
2306
2901
|
handle(method, agentId, segments, body, query) {
|
|
2307
2902
|
const agent = this.agents.find(agentId);
|
|
@@ -2330,6 +2925,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2330
2925
|
}
|
|
2331
2926
|
return action === 'shadow' ? this.triggerShadow(method, agent, id, body) : this.trigger(method, agent, id, body, query);
|
|
2332
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
|
+
}
|
|
2333
2931
|
return null;
|
|
2334
2932
|
}
|
|
2335
2933
|
configuration(agent) {
|
|
@@ -2350,12 +2948,18 @@ class NhAssistantMockRunConfiguration {
|
|
|
2350
2948
|
dataSkills,
|
|
2351
2949
|
codeTriggers: this.codeTriggers.filter(trigger => trigger.agentId === agent.id).map(({ agentId, ...trigger }) => trigger),
|
|
2352
2950
|
dataTriggers,
|
|
2353
|
-
problems:
|
|
2354
|
-
|
|
2355
|
-
|
|
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
|
+
],
|
|
2356
2957
|
source: agent.source,
|
|
2357
2958
|
runSettings: agent.source === 'admin' ? this.settings.get(agent.id) ?? null : null,
|
|
2358
|
-
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)
|
|
2359
2963
|
};
|
|
2360
2964
|
}
|
|
2361
2965
|
/**
|
|
@@ -2402,7 +3006,9 @@ class NhAssistantMockRunConfiguration {
|
|
|
2402
3006
|
if (!versionMatches(query, current.version)) {
|
|
2403
3007
|
return mockFailure('assistant-version-conflict');
|
|
2404
3008
|
}
|
|
2405
|
-
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)) {
|
|
2406
3012
|
return mockFailure('assistant-run-settings-in-use');
|
|
2407
3013
|
}
|
|
2408
3014
|
this.settings.delete(agent.id);
|
|
@@ -2925,11 +3531,14 @@ class NhAssistantMockAdmin {
|
|
|
2925
3531
|
hasServer: serverId => this.mcp.hasServer(serverId),
|
|
2926
3532
|
agentPolicyNames: () => this.runConfiguration.agentPolicyNames,
|
|
2927
3533
|
divisionsListed: () => this.runConfiguration.divisionsListed,
|
|
2928
|
-
profileNames: () => this.runConfiguration.profileNames
|
|
3534
|
+
profileNames: () => this.runConfiguration.profileNames,
|
|
3535
|
+
dataConsultations: agentId => this.runConfiguration.consultationsInEffect(agentId)
|
|
2929
3536
|
});
|
|
2930
3537
|
this.accounts = new NhAssistantMockAccounts(admin);
|
|
2931
3538
|
this.runConfiguration = new NhAssistantMockRunConfiguration(admin, {
|
|
2932
3539
|
find: agentId => this.agents.find(agentId),
|
|
3540
|
+
all: () => this.agents.all(),
|
|
3541
|
+
knownSubjectTypes: () => [...(scenario.subjectTypes ?? []).map(type => type.id), mockSystemSubjectType],
|
|
2933
3542
|
setDataSkill: (agentId, skillId, description, startsOff) => this.agents.setDataSkill(agentId, skillId, description, startsOff)
|
|
2934
3543
|
}, now, this.accounts);
|
|
2935
3544
|
this.mcp = new NhAssistantMockAdminMcp(scenario, now, {
|
|
@@ -3090,7 +3699,8 @@ class NhAssistantMockAdmin {
|
|
|
3090
3699
|
/**
|
|
3091
3700
|
* Saves the run configuration the scenario gives agents created in the administration through the
|
|
3092
3701
|
* mock's own endpoints, as an administrator would: the run settings, then each skill and trigger,
|
|
3093
|
-
* 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.
|
|
3094
3704
|
*/
|
|
3095
3705
|
presetRunConfigurations(agents) {
|
|
3096
3706
|
const query = new URLSearchParams();
|
|
@@ -3116,11 +3726,21 @@ class NhAssistantMockAdmin {
|
|
|
3116
3726
|
}
|
|
3117
3727
|
}
|
|
3118
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
|
+
}
|
|
3119
3734
|
}
|
|
3120
|
-
/**
|
|
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
|
+
*/
|
|
3121
3740
|
toolCatalog() {
|
|
3122
3741
|
return [
|
|
3123
3742
|
...(this.scenario.admin?.tools ?? []).map(tool => ({ ...tool, subjectType: tool.subjectType ?? null })),
|
|
3743
|
+
...this.runConfiguration.consultationTools(),
|
|
3124
3744
|
...this.mcp.catalogEntries()
|
|
3125
3745
|
];
|
|
3126
3746
|
}
|
|
@@ -3138,14 +3758,50 @@ function toContext(version) {
|
|
|
3138
3758
|
/** The longest file name a reference takes, as on the server. */
|
|
3139
3759
|
const maxNameLength = 256;
|
|
3140
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
|
+
];
|
|
3141
3778
|
/** How files of the platform's types start; other accepted types are the application's to check. */
|
|
3142
3779
|
const signatures = {
|
|
3143
3780
|
'application/pdf': head => indexOf(head, [0x25, 0x50, 0x44, 0x46, 0x2d]) >= 0,
|
|
3144
|
-
'image/png': head => startsWith(head,
|
|
3145
|
-
'image/jpeg': head => startsWith(head,
|
|
3146
|
-
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': head => startsWith(head,
|
|
3147
|
-
'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)
|
|
3148
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
|
+
}
|
|
3149
3805
|
/**
|
|
3150
3806
|
* The files of the mock API: `POST conversations/{id}/files` with the server's checks, and the
|
|
3151
3807
|
* attachments of a message, which a turn opens again as the server's file source would.
|
|
@@ -3185,7 +3841,8 @@ class NhAssistantMockFiles {
|
|
|
3185
3841
|
if (name === null || file.size === 0) {
|
|
3186
3842
|
return mockFailure('assistant-file-upload-invalid');
|
|
3187
3843
|
}
|
|
3188
|
-
|
|
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));
|
|
3189
3846
|
if (mediaType === null || !uploads.acceptedMediaTypes.includes(mediaType)) {
|
|
3190
3847
|
return mockFailure('assistant-file-type-not-accepted');
|
|
3191
3848
|
}
|
|
@@ -3193,7 +3850,7 @@ class NhAssistantMockFiles {
|
|
|
3193
3850
|
return mockFailure('assistant-file-too-large');
|
|
3194
3851
|
}
|
|
3195
3852
|
const head = new Uint8Array(await file.slice(0, 1024).arrayBuffer());
|
|
3196
|
-
if (!(
|
|
3853
|
+
if (!matchesSignature(head, mediaType)) {
|
|
3197
3854
|
return mockFailure('assistant-file-type-not-accepted');
|
|
3198
3855
|
}
|
|
3199
3856
|
const upload = { conversationId, name, mediaType, size: file.size };
|
|
@@ -3714,7 +4371,9 @@ class NhAssistantMockAgentRuns {
|
|
|
3714
4371
|
return method === 'GET' ? this.lookup(query, language) : { status: 405 };
|
|
3715
4372
|
}
|
|
3716
4373
|
if (segments.length === 3 && method === 'GET') {
|
|
3717
|
-
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;
|
|
3718
4377
|
if (listed) {
|
|
3719
4378
|
return { status: 200, body: listed };
|
|
3720
4379
|
}
|
|
@@ -4682,6 +5341,10 @@ const guid = /^[0-9a-f]{8}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{4}-?[0-9a-f]{12}$
|
|
|
4682
5341
|
const lifetimeMs = 3 * 24 * 60 * 60 * 1000;
|
|
4683
5342
|
/** The platform's subject type of the whole application. */
|
|
4684
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';
|
|
4685
5348
|
/**
|
|
4686
5349
|
* The proposals the scenario lists, which the mock's agents left, for example one a run for the whole
|
|
4687
5350
|
* application placed at a project (`placedBy`). The mock runs no agents: it serves them as the server
|
|
@@ -4691,7 +5354,10 @@ const systemType = 'nh-system';
|
|
|
4691
5354
|
* with the server's validation and statuses. An approved proposal executes at once. A proposal of the
|
|
4692
5355
|
* whole application (`nh-system`) exists only for the approvers of the whole application who pass the
|
|
4693
5356
|
* agent's view policy (`mayDecideApplication`): for anybody else the inbox leaves it out, reading it
|
|
4694
|
-
* 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`.
|
|
4695
5361
|
*/
|
|
4696
5362
|
class NhAssistantMockProposals {
|
|
4697
5363
|
constructor(scenario = [], actorId, now, mayDecideApplication = () => true, decided = () => undefined, mayWorkIn = () => true) {
|
|
@@ -4711,6 +5377,12 @@ class NhAssistantMockProposals {
|
|
|
4711
5377
|
if (!dashCase.test(item.subjectType)) {
|
|
4712
5378
|
throw new Error(`Mock proposal '${item.approvalId}' names subject type '${item.subjectType}', which is not dash-case.`);
|
|
4713
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;
|
|
4714
5386
|
this.proposals.set(item.approvalId, {
|
|
4715
5387
|
proposal: {
|
|
4716
5388
|
approvalId: item.approvalId,
|
|
@@ -4730,12 +5402,12 @@ class NhAssistantMockProposals {
|
|
|
4730
5402
|
argumentsPreview: '{}',
|
|
4731
5403
|
targets: [],
|
|
4732
5404
|
status: item.status ?? 'pending',
|
|
4733
|
-
mode: 'deferred',
|
|
5405
|
+
mode: route === 'asker-decides' ? 'inline' : 'deferred',
|
|
4734
5406
|
policyId: null,
|
|
4735
|
-
grantSource: 'agent',
|
|
5407
|
+
grantSource: item.grantSource ?? (route === 'subject-asker-rights' ? 'delegated' : 'agent'),
|
|
4736
5408
|
createdAt: item.createdAt ?? new Date(created).toISOString(),
|
|
4737
5409
|
expiresAt: item.expiresAt ?? new Date(Date.parse(item.createdAt ?? new Date(created).toISOString()) + lifetimeMs).toISOString(),
|
|
4738
|
-
initiatorActorId: null,
|
|
5410
|
+
initiatorActorId: item.initiatorActorId ?? null,
|
|
4739
5411
|
decidedByActorId: null,
|
|
4740
5412
|
decidedAt: null,
|
|
4741
5413
|
executionResultCode: null,
|
|
@@ -4743,11 +5415,13 @@ class NhAssistantMockProposals {
|
|
|
4743
5415
|
rationale: item.rationale ?? null,
|
|
4744
5416
|
placedBy: item.placedBy ?? null,
|
|
4745
5417
|
parent: item.parent ?? null,
|
|
4746
|
-
canGiveFeedback: false
|
|
5418
|
+
canGiveFeedback: false,
|
|
5419
|
+
consultationId: item.consultationId ?? null,
|
|
5420
|
+
consultationRoute: route
|
|
4747
5421
|
},
|
|
4748
5422
|
divisionId: item.divisionId ?? null,
|
|
4749
|
-
canDecide
|
|
4750
|
-
reasonCode:
|
|
5423
|
+
canDecide,
|
|
5424
|
+
reasonCode: canDecide ? null : item.reasonCode ?? refusal ?? 'assistant-approval-policy-denied'
|
|
4751
5425
|
});
|
|
4752
5426
|
}
|
|
4753
5427
|
}
|
|
@@ -4781,6 +5455,21 @@ class NhAssistantMockProposals {
|
|
|
4781
5455
|
.reverse();
|
|
4782
5456
|
return items.length === 0 ? null : items;
|
|
4783
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
|
+
}
|
|
4784
5473
|
/** The proposals of a run the caller may see, in the order the scenario lists them. */
|
|
4785
5474
|
ofRun(runId) {
|
|
4786
5475
|
return this.visible()
|
|
@@ -4946,6 +5635,17 @@ class NhAssistantMockProposals {
|
|
|
4946
5635
|
};
|
|
4947
5636
|
}
|
|
4948
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
|
+
}
|
|
4949
5649
|
/** The scope of the inbox's visits: `global`, or the scope key of a division. */
|
|
4950
5650
|
function scopeKeyOf(divisionId) {
|
|
4951
5651
|
return divisionId === null ? 'global' : `division:${divisionId.replaceAll('-', '').toLowerCase()}`;
|
|
@@ -5080,6 +5780,8 @@ const NH_ASSISTANT_MOCK_SCENARIO = new InjectionToken('NH_ASSISTANT_MOCK_SCENARI
|
|
|
5080
5780
|
const NH_ASSISTANT_MOCK_HUB_PATH = '/hub/assistant-mock';
|
|
5081
5781
|
const defaultLimits = { maxMessageChars: 4_000, maxToolCallsPerTurn: 8 };
|
|
5082
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();
|
|
5083
5785
|
class RunStopped extends Error {
|
|
5084
5786
|
}
|
|
5085
5787
|
/**
|
|
@@ -5112,6 +5814,7 @@ class NhAssistantMockBackend {
|
|
|
5112
5814
|
agents: () => this.admin.allAgents(),
|
|
5113
5815
|
administers: () => this.admin.administers,
|
|
5114
5816
|
threadProposals: conversationId => this.proposals.ofThread(conversationId),
|
|
5817
|
+
threadConsultations: conversationId => this.consultationsOf(conversationId),
|
|
5115
5818
|
runProposals: runId => this.proposals.ofRun(runId),
|
|
5116
5819
|
workChanged: (change, agentId) => this.publishAgentWork(change, agentId)
|
|
5117
5820
|
}, () => new Date().toISOString());
|
|
@@ -5232,6 +5935,37 @@ class NhAssistantMockBackend {
|
|
|
5232
5935
|
const turn = this.findTurn(text, conversation.agentId, null);
|
|
5233
5936
|
await this.playSteps(conversationId, assistantMessageId, turn?.steps ?? [], emit);
|
|
5234
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
|
+
}
|
|
5235
5969
|
/** Switches the simulated `NewHeap:AI:Assistant:Enabled` flag. */
|
|
5236
5970
|
setEnabled(enabled) {
|
|
5237
5971
|
this.enabledState.set(enabled);
|
|
@@ -5480,6 +6214,12 @@ class NhAssistantMockBackend {
|
|
|
5480
6214
|
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
5481
6215
|
});
|
|
5482
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
|
+
*/
|
|
5483
6223
|
decide(conversation, approvalId, request) {
|
|
5484
6224
|
const pending = this.pendingApprovals.get(conversation.id);
|
|
5485
6225
|
if (!pending || pending.approval.approvalId !== approvalId) {
|
|
@@ -5489,16 +6229,29 @@ class NhAssistantMockBackend {
|
|
|
5489
6229
|
if (decider && decider !== this.currentUser.actorId) {
|
|
5490
6230
|
return this.error('assistant-approval-forbidden');
|
|
5491
6231
|
}
|
|
5492
|
-
|
|
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) {
|
|
5493
6243
|
return this.empty(409);
|
|
5494
6244
|
}
|
|
5495
6245
|
if (Date.parse(pending.approval.expiresAt) <= Date.now()) {
|
|
5496
6246
|
return this.empty(409);
|
|
5497
6247
|
}
|
|
5498
6248
|
this.pendingApprovals.delete(conversation.id);
|
|
5499
|
-
const decision = request.decision;
|
|
5500
6249
|
const lastUser = [...conversation.messages].reverse().find(message => message.role === 'user');
|
|
5501
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
|
+
}
|
|
5502
6255
|
return this.stream(conversation.id, async (emit) => {
|
|
5503
6256
|
await emit({
|
|
5504
6257
|
type: 'turn.started',
|
|
@@ -5511,6 +6264,14 @@ class NhAssistantMockBackend {
|
|
|
5511
6264
|
});
|
|
5512
6265
|
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.approved, emit);
|
|
5513
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
|
+
}
|
|
5514
6275
|
else {
|
|
5515
6276
|
await this.playSteps(conversation.id, pending.assistantMessageId, pending.step.rejected, emit);
|
|
5516
6277
|
}
|
|
@@ -5620,7 +6381,21 @@ class NhAssistantMockBackend {
|
|
|
5620
6381
|
argumentsPreview: step.argumentsPreview,
|
|
5621
6382
|
targets: step.targets,
|
|
5622
6383
|
expiresAt: new Date(Date.now() + (step.expiresInSeconds ?? 300) * 1000).toISOString(),
|
|
5623
|
-
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
|
+
: {})
|
|
5624
6399
|
};
|
|
5625
6400
|
this.pendingApprovals.set(conversationId, { approval, invocationId, assistantMessageId, step });
|
|
5626
6401
|
await emit({ type: 'approval.required', data: approval });
|
|
@@ -5876,6 +6651,40 @@ class NhAssistantMockBackend {
|
|
|
5876
6651
|
const policy = agent?.viewPolicy ?? agent?.requiredPolicy ?? null;
|
|
5877
6652
|
return policy === null || (this.scenario.callerPolicies?.includes(policy) ?? true);
|
|
5878
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
|
+
}
|
|
5879
6688
|
nextId() {
|
|
5880
6689
|
this.sequence++;
|
|
5881
6690
|
const suffix = this.sequence.toString(16).padStart(12, '0');
|
|
@@ -5917,6 +6726,19 @@ function languageOf(headers) {
|
|
|
5917
6726
|
function delay(milliseconds) {
|
|
5918
6727
|
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
5919
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
|
+
}
|
|
5920
6742
|
/** Like the server: a new message starts in an idle conversation or after a failed turn. */
|
|
5921
6743
|
function acceptsMessage(status) {
|
|
5922
6744
|
return status === 'idle' || status === 'failed';
|