@newheap/platform-ai-chat 0.20.0 → 0.22.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 +12 -2
- package/admin/i18n/en.json +1738 -0
- package/admin/i18n/nl.json +1738 -0
- package/admin/index.d.ts +47 -6
- package/fesm2022/newheap-platform-ai-chat-admin.mjs +3626 -25
- package/fesm2022/newheap-platform-ai-chat-admin.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat-testing.mjs +388 -27
- package/fesm2022/newheap-platform-ai-chat-testing.mjs.map +1 -1
- package/fesm2022/newheap-platform-ai-chat.mjs +164 -3490
- package/fesm2022/newheap-platform-ai-chat.mjs.map +1 -1
- package/i18n/en.json +9 -1726
- package/i18n/nl.json +9 -1726
- package/index.d.ts +101 -16
- package/package.json +4 -1
- package/testing/index.d.ts +16 -0
|
@@ -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 {
|
|
3
|
+
import { NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION, NH_ASSISTANT_AGENT_PACKAGE_FORMAT, NH_ASSISTANT_AGENT_PACKAGE_MAX_AGENTS, NH_ASSISTANT_PACKAGE_LOCK_ALL, NH_ASSISTANT_PACKAGE_LOCK_FIELDS, NH_ASSISTANT_AGENT_PACKAGE_MAX_BYTES, NH_ASSISTANT_UPLOAD_HEADER, ɵnhAssistantMediaTypeOf as _nhAssistantMediaTypeOf, NH_ASSISTANT_MAX_PROPOSAL_LOOKUP, NH_ASSISTANT_CONFIG, applyNhAssistantEvent, normalizeNhAssistantClientContext, applyNhAssistantApprovalDecision, applyNhAssistantQuestionAnswer, NH_ASSISTANT_FETCH, NH_ASSISTANT_UPLOAD, NhAssistantLiveService, NH_ASSISTANT_MCP_AUTHORIZATION_WINDOW } from '@newheap/platform-ai-chat';
|
|
4
4
|
import { Observable, Subject } from 'rxjs';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -65,7 +65,8 @@ const statusTable = {
|
|
|
65
65
|
'assistant-forbidden',
|
|
66
66
|
'assistant-mcp-credential-mismatch',
|
|
67
67
|
'assistant-mcp-initiator-only',
|
|
68
|
-
'assistant-file-forbidden'
|
|
68
|
+
'assistant-file-forbidden',
|
|
69
|
+
'assistant-not-allowed-while-impersonating'
|
|
69
70
|
],
|
|
70
71
|
404: [
|
|
71
72
|
'assistant-disabled',
|
|
@@ -399,6 +400,10 @@ function mockWorksOnErrors(worksOn, knownTypes, agentPolicies, divisions) {
|
|
|
399
400
|
}
|
|
400
401
|
/** Whether an agent works on subjects of the type, as the server's rule decides it. */
|
|
401
402
|
function mockWorksOnCovers(agent, subjectType) {
|
|
403
|
+
// A chat-only agent (`ChatOnly()` in code) works on no subject at all.
|
|
404
|
+
if (agent.chatOnly === true) {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
402
407
|
if (subjectType === mockSystemSubjectType) {
|
|
403
408
|
return agent.worksOnApplication === true;
|
|
404
409
|
}
|
|
@@ -418,6 +423,8 @@ const consultationRoutes = [
|
|
|
418
423
|
const mockChatProfile = 'chat';
|
|
419
424
|
const autonomyLevels = ['observe', 'explain', 'propose', 'simulate', 'execute'];
|
|
420
425
|
const dataClassifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
426
|
+
/** The most MCP servers one agent is assigned to, as the server allows. */
|
|
427
|
+
const maxServersPerAgent = 32;
|
|
421
428
|
/**
|
|
422
429
|
* The agents of the mock administration: agents in code and agents created as data, their state,
|
|
423
430
|
* shadow modes, technical accounts, feedback, evaluation and replays. The mock runs no agents on
|
|
@@ -434,18 +441,24 @@ class NhAssistantMockAdminAgents {
|
|
|
434
441
|
const agents = admin.agents
|
|
435
442
|
?? scenario.agents.map(agent => ({ ...codeAgentFromSummary(agent), source: 'code' }));
|
|
436
443
|
// How an agent runs, in code or as data, belongs to its run configuration, not to the agent.
|
|
437
|
-
for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
|
|
444
|
+
for (const { source, skills, shadowOf, shadow, serviceAccount, worksOnDeclared, knownSubjectTypes, consultations, consultationDescriptions, chatOnly, codeRunSettings: _, runConfiguration: __, consultableAsData: ___, dataConsultations: ____, consultationTools: _____, codeHandOffs: ______, ...input } of agents) {
|
|
438
445
|
const variant = typeof shadowOf === 'string';
|
|
439
446
|
const agentShadow = variant || shadow === true;
|
|
447
|
+
// A chat-only agent has no shadow threads: it and its skills always work live.
|
|
448
|
+
const live = source === 'code' && chatOnly === true;
|
|
440
449
|
const initial = this.toAgent(input, source, 1, false);
|
|
441
450
|
this.agents.set(input.id, {
|
|
442
451
|
agent: {
|
|
443
452
|
...initial,
|
|
444
453
|
// Code says what its agents work on; a scenario agent that says nothing works on all objects.
|
|
445
454
|
...(source === 'code' ? { ...(mockWorksOnFromInput(input) ?? mockUndeclaredWorksOn), worksOnDeclared: worksOnDeclared ?? mockWorksOnFromInput(input) !== null } : {}),
|
|
455
|
+
// A chat-only agent in code (`ChatOnly()`) works on nothing, said on purpose.
|
|
456
|
+
...(source === 'code' && chatOnly === true
|
|
457
|
+
? { chatOnly: true, worksOnSubjectTypes: [], worksOnAllSubjects: false, worksOnApplication: false, perDivision: false, worksOnDeclared: true }
|
|
458
|
+
: {}),
|
|
446
459
|
knownSubjectTypes: knownSubjectTypes ?? [],
|
|
447
460
|
shadowOf: shadowOf ?? null,
|
|
448
|
-
shadow: shadowState(agentShadow, variant || shadow === true, null, variant),
|
|
461
|
+
shadow: shadowState(agentShadow, variant || shadow === true, null, variant || live),
|
|
449
462
|
serviceAccount: serviceAccount === undefined
|
|
450
463
|
? null
|
|
451
464
|
: { actorId: serviceAccount, configuredActorId: serviceAccount, overridden: false, changedAt: null, changedBy: null, reason: null },
|
|
@@ -463,7 +476,7 @@ class NhAssistantMockAdminAgents {
|
|
|
463
476
|
dailyTokens: null,
|
|
464
477
|
cancelledRuns: 0,
|
|
465
478
|
startedRuns: 0,
|
|
466
|
-
shadow: shadowState(agentShadow || skill.shadow === true, skill.shadow === true, null, variant)
|
|
479
|
+
shadow: shadowState(!live && (agentShadow || skill.shadow === true), skill.shadow === true, null, variant || live)
|
|
467
480
|
}))
|
|
468
481
|
},
|
|
469
482
|
codeDefinition: source === 'code' ? structuredClone(input) : null,
|
|
@@ -543,6 +556,29 @@ class NhAssistantMockAdminAgents {
|
|
|
543
556
|
live.agent = this.toAgent(definition, live.agent.source, live.agent.version + 1, live.agent.source === 'code', live.agent);
|
|
544
557
|
return live.agent;
|
|
545
558
|
}
|
|
559
|
+
/**
|
|
560
|
+
* Assigns an MCP server to an agent or removes the assignment, as `PUT` and
|
|
561
|
+
* `DELETE admin/mcp-servers/{id}/agents/{agentId}` do: only the assignment changes, never the agent's
|
|
562
|
+
* version; an agent has at most 32 servers.
|
|
563
|
+
*/
|
|
564
|
+
setMcpAssignment(agentId, serverId, assign) {
|
|
565
|
+
const stored = this.agents.get(agentId);
|
|
566
|
+
if (!stored) {
|
|
567
|
+
return 'agent-not-found';
|
|
568
|
+
}
|
|
569
|
+
const assigned = stored.agent.mcpServerIds.includes(serverId);
|
|
570
|
+
if (assigned === assign) {
|
|
571
|
+
return 'unchanged';
|
|
572
|
+
}
|
|
573
|
+
if (assign && stored.agent.mcpServerIds.length >= maxServersPerAgent) {
|
|
574
|
+
return 'too-long';
|
|
575
|
+
}
|
|
576
|
+
stored.agent = {
|
|
577
|
+
...stored.agent,
|
|
578
|
+
mcpServerIds: assign ? [...stored.agent.mcpServerIds, serverId] : stored.agent.mcpServerIds.filter(id => id !== serverId)
|
|
579
|
+
};
|
|
580
|
+
return 'changed';
|
|
581
|
+
}
|
|
546
582
|
/** Removes a deleted MCP server from the agents it was assigned to. */
|
|
547
583
|
removeServer(serverId) {
|
|
548
584
|
for (const agent of this.agents.values()) {
|
|
@@ -600,7 +636,7 @@ class NhAssistantMockAdminAgents {
|
|
|
600
636
|
if (refused) {
|
|
601
637
|
return refused;
|
|
602
638
|
}
|
|
603
|
-
const created = this.toAgent(input, 'admin', 1, false);
|
|
639
|
+
const created = this.toAgent({ ...input, mcpServerIds: input.mcpServerIds ?? [] }, 'admin', 1, false);
|
|
604
640
|
// A data shadow variant always works in shadow and is never offered in the chat.
|
|
605
641
|
const agent = shadowOf === null
|
|
606
642
|
? created
|
|
@@ -660,7 +696,8 @@ class NhAssistantMockAdminAgents {
|
|
|
660
696
|
return mockFailure('assistant-version-conflict');
|
|
661
697
|
}
|
|
662
698
|
const { expectedVersion, ...definition } = input;
|
|
663
|
-
|
|
699
|
+
// An omitted list keeps the agent's assignments; an empty list removes them.
|
|
700
|
+
const updated = this.toAgent({ ...definition, mcpServerIds: definition.mcpServerIds ?? stored.agent.mcpServerIds }, stored.agent.source, stored.agent.version + 1, stored.agent.source === 'code', stored.agent);
|
|
664
701
|
// A data shadow variant keeps its live agent and stays out of the chat.
|
|
665
702
|
stored.agent = stored.agent.source === 'admin' && stored.agent.shadowOf ? { ...updated, offeredInChat: false } : updated;
|
|
666
703
|
return { status: 200, body: stored.agent };
|
|
@@ -714,6 +751,10 @@ class NhAssistantMockAdminAgents {
|
|
|
714
751
|
if (request === null) {
|
|
715
752
|
return mockValidationFailure();
|
|
716
753
|
}
|
|
754
|
+
// The skills of a chat-only agent always work live.
|
|
755
|
+
if (stored.agent.chatOnly === true) {
|
|
756
|
+
return mockFailure('assistant-agent-does-not-work-on');
|
|
757
|
+
}
|
|
717
758
|
const agentShadow = stored.agent.shadow ?? shadowState(false, false, null, false);
|
|
718
759
|
if (agentShadow.locked) {
|
|
719
760
|
return mockFailure('assistant-shadow-variant');
|
|
@@ -888,6 +929,10 @@ class NhAssistantMockAdminAgents {
|
|
|
888
929
|
if (request === null) {
|
|
889
930
|
return mockValidationFailure();
|
|
890
931
|
}
|
|
932
|
+
// A chat-only agent has no runs that could work in shadow threads.
|
|
933
|
+
if (stored.agent.chatOnly === true) {
|
|
934
|
+
return mockFailure('assistant-agent-does-not-work-on');
|
|
935
|
+
}
|
|
891
936
|
const current = stored.agent.shadow ?? shadowState(false, false, null, false);
|
|
892
937
|
if (current.locked) {
|
|
893
938
|
return mockFailure('assistant-shadow-variant');
|
|
@@ -1128,7 +1173,9 @@ class NhAssistantMockAdminAgents {
|
|
|
1128
1173
|
typeof input.description === 'string' &&
|
|
1129
1174
|
typeof input.instructions === 'string' && input.instructions.length <= mockMaxInstructions &&
|
|
1130
1175
|
Array.isArray(input.toolSelectors) && input.toolSelectors.every(selector => /^[a-z0-9*][a-z0-9.*-]*$/.test(selector)) &&
|
|
1131
|
-
|
|
1176
|
+
// Omitted, a new agent has no servers and an update keeps them, as on the server.
|
|
1177
|
+
(input.mcpServerIds === undefined || input.mcpServerIds === null ||
|
|
1178
|
+
(Array.isArray(input.mcpServerIds) && input.mcpServerIds.every(serverId => this.rules.hasServer(serverId)))) &&
|
|
1132
1179
|
(input.requiredPolicy === null || (typeof input.requiredPolicy === 'string' && (!policies || policies.includes(input.requiredPolicy)))) &&
|
|
1133
1180
|
autonomyLevels.includes(input.autonomy) &&
|
|
1134
1181
|
(input.isEnabled === undefined || typeof input.isEnabled === 'boolean');
|
|
@@ -1151,6 +1198,8 @@ class NhAssistantMockAdminAgents {
|
|
|
1151
1198
|
const classification = chosenClassification(dataClassification);
|
|
1152
1199
|
return this.withWarnings({
|
|
1153
1200
|
...definition,
|
|
1201
|
+
// The callers name the servers; an omitted list keeps those of the agent, or none.
|
|
1202
|
+
mcpServerIds: definition.mcpServerIds ?? previous?.mcpServerIds ?? [],
|
|
1154
1203
|
// An agent created here keeps its profile when the input omits it; code decides for the others.
|
|
1155
1204
|
profile: chosenProfile ?? previous?.profile ?? mockChatProfile,
|
|
1156
1205
|
// The same for its classification; an agent in code keeps the one of its scenario.
|
|
@@ -1317,6 +1366,9 @@ const readerPriority = { min: -1000, max: 1000 };
|
|
|
1317
1366
|
const classifications$1 = ['public', 'internal', 'confidential', 'restricted'];
|
|
1318
1367
|
const mediaTypePattern$1 = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/;
|
|
1319
1368
|
const controlCharacters = /[\u0000-\u001f\u007f]/;
|
|
1369
|
+
/** The longest remote tool name a sync stores, and the most tools of a server that wait for a sync. */
|
|
1370
|
+
const maxRemoteName = 128;
|
|
1371
|
+
const maxToolsAwaitingSync = 100;
|
|
1320
1372
|
/**
|
|
1321
1373
|
* The MCP servers of the mock administration and the caller's own connections: secrets are
|
|
1322
1374
|
* write-only, new tools start disabled as mutations, a changed remote schema disables a tool, OAuth
|
|
@@ -1636,6 +1688,10 @@ class NhAssistantMockAdminMcp {
|
|
|
1636
1688
|
stored.organization?.status === 'issuer-changed') {
|
|
1637
1689
|
stored.organization = { ...stored.organization, status: 'active' };
|
|
1638
1690
|
}
|
|
1691
|
+
if (!keepsSecret(before, input)) {
|
|
1692
|
+
// As on the server: a stored secret never follows the server to another destination.
|
|
1693
|
+
stored.secret = null;
|
|
1694
|
+
}
|
|
1639
1695
|
if (!secretModes.includes(input.authMode)) {
|
|
1640
1696
|
stored.secret = null;
|
|
1641
1697
|
stored.server.secretReference = null;
|
|
@@ -1744,19 +1800,20 @@ class NhAssistantMockAdminMcp {
|
|
|
1744
1800
|
if (length === 5 && action === 'tools' && method === 'PUT' && child !== undefined) {
|
|
1745
1801
|
const tool = stored.tools.get(child);
|
|
1746
1802
|
const input = body;
|
|
1747
|
-
if (!tool) {
|
|
1748
|
-
return mockFailure('assistant-mcp-tool-not-found');
|
|
1749
|
-
}
|
|
1750
1803
|
if (typeof input?.isEnabled !== 'boolean' || (input.effect !== 'read-only' && input.effect !== 'mutation') ||
|
|
1751
1804
|
(input.descriptionOverride !== null && typeof input.descriptionOverride !== 'string')) {
|
|
1752
1805
|
return mockValidationFailure();
|
|
1753
1806
|
}
|
|
1754
|
-
if (input.isEnabled && tool.status === 'missing') {
|
|
1755
|
-
return mockValidationFailure('isEnabled');
|
|
1756
|
-
}
|
|
1757
1807
|
const override = typeof input.descriptionOverride === 'string' && input.descriptionOverride.trim().length > 0
|
|
1758
1808
|
? input.descriptionOverride.slice(0, 1_000)
|
|
1759
1809
|
: null;
|
|
1810
|
+
if (!tool) {
|
|
1811
|
+
return this.reviewBeforeSync(stored, child, input.isEnabled, input.effect, override);
|
|
1812
|
+
}
|
|
1813
|
+
// A tool the server no longer lists cannot be enabled; one that no sync has seen yet can.
|
|
1814
|
+
if (input.isEnabled && tool.status === 'missing' && !tool.awaitingSync) {
|
|
1815
|
+
return mockFailure('assistant-mcp-tool-disabled');
|
|
1816
|
+
}
|
|
1760
1817
|
const updated = {
|
|
1761
1818
|
...tool,
|
|
1762
1819
|
isEnabled: input.isEnabled,
|
|
@@ -1767,14 +1824,77 @@ class NhAssistantMockAdminMcp {
|
|
|
1767
1824
|
stored.tools.set(child, updated);
|
|
1768
1825
|
return { status: 200, body: toTool(updated) };
|
|
1769
1826
|
}
|
|
1827
|
+
if (length === 5 && action === 'agents' && child !== undefined) {
|
|
1828
|
+
if (method !== 'PUT' && method !== 'DELETE') {
|
|
1829
|
+
return { status: 405 };
|
|
1830
|
+
}
|
|
1831
|
+
// Only the assignment changes, never the agent's version, as on the server.
|
|
1832
|
+
const changed = this.context.setAssignment(stored.server.id, child, method === 'PUT');
|
|
1833
|
+
if (changed === 'agent-not-found') {
|
|
1834
|
+
return mockFailure('assistant-agent-not-found');
|
|
1835
|
+
}
|
|
1836
|
+
if (changed === 'too-long') {
|
|
1837
|
+
return mockFailure('assistant-validation', { mcpServerIds: ['too-long'] });
|
|
1838
|
+
}
|
|
1839
|
+
return { status: 200, body: this.toServer(stored) };
|
|
1840
|
+
}
|
|
1770
1841
|
return mockFailure('assistant-not-found');
|
|
1771
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* The reviewed settings of a tool that no sync has seen yet, as the server stores them: a missing tool
|
|
1845
|
+
* without a schema that waits for its sync, with a name a sync could store and no other spelling of
|
|
1846
|
+
* a stored tool's name; at most 100 per server.
|
|
1847
|
+
*/
|
|
1848
|
+
reviewBeforeSync(stored, remoteName, isEnabled, effect, descriptionOverride) {
|
|
1849
|
+
if (remoteName.trim().length === 0) {
|
|
1850
|
+
return mockFailure('assistant-validation', { remoteName: ['required'] });
|
|
1851
|
+
}
|
|
1852
|
+
if (remoteName.length > maxRemoteName) {
|
|
1853
|
+
return mockFailure('assistant-validation', { remoteName: ['too-long'] });
|
|
1854
|
+
}
|
|
1855
|
+
const lower = remoteName.toLowerCase();
|
|
1856
|
+
if (controlCharacters.test(remoteName) || [...stored.tools.keys()].some(name => name.toLowerCase() === lower)) {
|
|
1857
|
+
return mockFailure('assistant-validation', { remoteName: ['invalid'] });
|
|
1858
|
+
}
|
|
1859
|
+
if ([...stored.tools.values()].filter(tool => tool.awaitingSync).length >= maxToolsAwaitingSync) {
|
|
1860
|
+
return mockFailure('assistant-configuration-limit');
|
|
1861
|
+
}
|
|
1862
|
+
const tool = {
|
|
1863
|
+
remoteName,
|
|
1864
|
+
localId: `mcp.${stored.server.id}.${kebab(remoteName)}`,
|
|
1865
|
+
description: '',
|
|
1866
|
+
descriptionOverride,
|
|
1867
|
+
isEnabled,
|
|
1868
|
+
effect,
|
|
1869
|
+
status: 'missing',
|
|
1870
|
+
readOnlyHint: null,
|
|
1871
|
+
awaitingSync: true,
|
|
1872
|
+
inputSchemaHash: ''
|
|
1873
|
+
};
|
|
1874
|
+
stored.tools.set(remoteName, tool);
|
|
1875
|
+
return { status: 200, body: toTool(tool) };
|
|
1876
|
+
}
|
|
1772
1877
|
sync(stored) {
|
|
1773
1878
|
const seen = new Set();
|
|
1774
1879
|
for (const remote of stored.remoteTools) {
|
|
1775
1880
|
seen.add(remote.remoteName);
|
|
1776
|
-
const existing = stored.tools.get(remote.remoteName);
|
|
1881
|
+
const existing = stored.tools.get(remote.remoteName) ?? this.reviewedInOtherSpelling(stored, remote.remoteName);
|
|
1777
1882
|
const description = remote.description.slice(0, 1_000);
|
|
1883
|
+
if (existing?.awaitingSync) {
|
|
1884
|
+
// The first sync that sees a reviewed tool keeps its settings and takes the server's spelling.
|
|
1885
|
+
stored.tools.delete(existing.remoteName);
|
|
1886
|
+
stored.tools.set(remote.remoteName, {
|
|
1887
|
+
...existing,
|
|
1888
|
+
remoteName: remote.remoteName,
|
|
1889
|
+
localId: `mcp.${stored.server.id}.${kebab(remote.remoteName)}`,
|
|
1890
|
+
description,
|
|
1891
|
+
status: 'available',
|
|
1892
|
+
readOnlyHint: remote.readOnlyHint ?? null,
|
|
1893
|
+
awaitingSync: false,
|
|
1894
|
+
inputSchemaHash: remote.inputSchemaHash
|
|
1895
|
+
});
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1778
1898
|
if (!existing) {
|
|
1779
1899
|
stored.tools.set(remote.remoteName, {
|
|
1780
1900
|
remoteName: remote.remoteName,
|
|
@@ -1800,11 +1920,19 @@ class NhAssistantMockAdminMcp {
|
|
|
1800
1920
|
});
|
|
1801
1921
|
}
|
|
1802
1922
|
for (const [name, tool] of stored.tools) {
|
|
1803
|
-
|
|
1923
|
+
// A reviewed tool that this sync does not see keeps waiting with its settings.
|
|
1924
|
+
if (!seen.has(name) && !tool.awaitingSync) {
|
|
1804
1925
|
stored.tools.set(name, { ...tool, isEnabled: false, status: 'missing' });
|
|
1805
1926
|
}
|
|
1806
1927
|
}
|
|
1807
1928
|
}
|
|
1929
|
+
/** A reviewed tool that waits for its sync under another spelling of a name no remote tool has. */
|
|
1930
|
+
reviewedInOtherSpelling(stored, remoteName) {
|
|
1931
|
+
const lower = remoteName.toLowerCase();
|
|
1932
|
+
const names = new Set(stored.remoteTools.map(remote => remote.remoteName));
|
|
1933
|
+
return [...stored.tools.values()]
|
|
1934
|
+
.find(tool => tool.awaitingSync && !names.has(tool.remoteName) && tool.remoteName.toLowerCase() === lower);
|
|
1935
|
+
}
|
|
1808
1936
|
/**
|
|
1809
1937
|
* The field errors of a server change, as the server reports them in one refusal: the fields
|
|
1810
1938
|
* themselves, the credential and the OAuth settings. Empty when the change is valid.
|
|
@@ -2124,17 +2252,20 @@ class NhAssistantMockAdminMcp {
|
|
|
2124
2252
|
if (input.secret && input.secretReference) {
|
|
2125
2253
|
add('secretReference', 'invalid');
|
|
2126
2254
|
}
|
|
2255
|
+
// As on the server: a stored secret and reference serve only the destination they were given for.
|
|
2256
|
+
const keeps = keepsSecret(stored?.server, input);
|
|
2127
2257
|
const reference = input.secretReference === undefined || input.secretReference === null
|
|
2128
|
-
? (input.secret ? null : stored?.server.secretReference ?? null)
|
|
2258
|
+
? (input.secret || !keeps ? null : stored?.server.secretReference ?? null)
|
|
2129
2259
|
: input.secretReference || null;
|
|
2130
2260
|
if (reference !== null && (!secretReference.test(reference) || reference.includes('..'))) {
|
|
2131
2261
|
add('secretReference', 'invalid');
|
|
2132
2262
|
}
|
|
2133
2263
|
if (secretModes.includes(input.authMode)) {
|
|
2134
2264
|
// As on the server: bearer, api-key and basic need a stored secret or a reference. A new stored
|
|
2135
|
-
// secret replaces a reference, a new reference a stored secret
|
|
2265
|
+
// secret replaces a reference, a new reference a stored secret, and another destination needs
|
|
2266
|
+
// either again.
|
|
2136
2267
|
const storedSecret = input.secret === undefined || input.secret === null
|
|
2137
|
-
? !!stored?.secret && !input.secretReference
|
|
2268
|
+
? keeps && !!stored?.secret && !input.secretReference
|
|
2138
2269
|
: input.secret.length > 0;
|
|
2139
2270
|
if (!storedSecret && (reference === null || !!input.secret)) {
|
|
2140
2271
|
add('secret', 'required');
|
|
@@ -2208,10 +2339,32 @@ class NhAssistantMockAdminMcp {
|
|
|
2208
2339
|
};
|
|
2209
2340
|
}
|
|
2210
2341
|
}
|
|
2342
|
+
/**
|
|
2343
|
+
* Whether a stored secret and reference stay with the server after the input, as on the server: the
|
|
2344
|
+
* URL keeps its origin (scheme, host and port), and the authentication mode and the `api-key` header,
|
|
2345
|
+
* in any letter case, stay the same.
|
|
2346
|
+
*/
|
|
2347
|
+
function keepsSecret(stored, input) {
|
|
2348
|
+
const before = stored ? originOf(stored.url) : null;
|
|
2349
|
+
if (!stored || before === null || stored.authMode !== input.authMode || before !== originOf(input.url)) {
|
|
2350
|
+
return false;
|
|
2351
|
+
}
|
|
2352
|
+
return input.authMode !== 'api-key'
|
|
2353
|
+
|| (stored.headerName || 'X-Api-Key').toLowerCase() === (input.headerName || 'X-Api-Key').toLowerCase();
|
|
2354
|
+
}
|
|
2355
|
+
/** The origin of an absolute URL in lowercase, or `null` for an invalid one. */
|
|
2356
|
+
function originOf(url) {
|
|
2357
|
+
try {
|
|
2358
|
+
return new URL(url.trim()).origin.toLowerCase();
|
|
2359
|
+
}
|
|
2360
|
+
catch {
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2211
2364
|
function toServerFields(input, stored) {
|
|
2212
2365
|
const usesSecret = secretModes.includes(input.authMode);
|
|
2213
2366
|
const reference = input.secretReference === undefined || input.secretReference === null
|
|
2214
|
-
? (input.secret ? null : stored?.secretReference ?? null)
|
|
2367
|
+
? (input.secret || !keepsSecret(stored, input) ? null : stored?.secretReference ?? null)
|
|
2215
2368
|
: input.secretReference || null;
|
|
2216
2369
|
const certificate = input.clientCertificateReference === undefined || input.clientCertificateReference === null
|
|
2217
2370
|
? stored?.clientCertificateReference ?? null
|
|
@@ -2482,7 +2635,7 @@ function allowedServerUrl(value) {
|
|
|
2482
2635
|
}
|
|
2483
2636
|
}
|
|
2484
2637
|
function toTool({ inputSchemaHash, ...tool }) {
|
|
2485
|
-
return tool;
|
|
2638
|
+
return { ...tool, awaitingSync: tool.awaitingSync === true };
|
|
2486
2639
|
}
|
|
2487
2640
|
function toTools(stored) {
|
|
2488
2641
|
return [...stored.tools.values()].map(toTool);
|
|
@@ -2789,7 +2942,9 @@ class NhAssistantMockHandOffs {
|
|
|
2789
2942
|
...data.map(agent => {
|
|
2790
2943
|
const hasRunSettings = this.agents.isConfigured(agent);
|
|
2791
2944
|
return {
|
|
2792
|
-
agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version
|
|
2945
|
+
agentId: agent.id, displayName: agent.displayName, source: 'data', hasRunSettings, inEffect: hasRunSettings, problem: null, version: agent.version,
|
|
2946
|
+
packageId: null,
|
|
2947
|
+
packageVersion: null
|
|
2793
2948
|
};
|
|
2794
2949
|
})
|
|
2795
2950
|
]
|
|
@@ -4435,6 +4590,7 @@ class NhAssistantMockAdmin {
|
|
|
4435
4590
|
agents: () => this.agents.all(),
|
|
4436
4591
|
chatAgentIds: () => this.agents.chatAgents().map(agent => agent.id),
|
|
4437
4592
|
removeServer: serverId => this.agents.removeServer(serverId),
|
|
4593
|
+
setAssignment: (serverId, agentId, assign) => this.agents.setMcpAssignment(agentId, serverId, assign),
|
|
4438
4594
|
canAdminister: () => this.canAdminister
|
|
4439
4595
|
});
|
|
4440
4596
|
this.presetRunConfigurations(admin.agents ?? []);
|
|
@@ -4866,8 +5022,139 @@ class NhAssistantMockAgentPackages {
|
|
|
4866
5022
|
if (segments[1] === 'agents' && segments[3] === 'package' && segments[4] === 'unlink' && segments.length === 5) {
|
|
4867
5023
|
return method === 'POST' ? this.unlink(segments[2], body) : { status: 405 };
|
|
4868
5024
|
}
|
|
5025
|
+
// A promotion as the package's version is an import of the variant's parts onto its live agent.
|
|
5026
|
+
if (segments[1] === 'agents' && segments[3] === 'promote' && segments.length === 4
|
|
5027
|
+
&& body?.asPackageVersion === true) {
|
|
5028
|
+
return method === 'POST' ? this.promoteAsPackageVersion(segments[2], body) : { status: 405 };
|
|
5029
|
+
}
|
|
4869
5030
|
return null;
|
|
4870
5031
|
}
|
|
5032
|
+
/**
|
|
5033
|
+
* `POST admin/agents/{id}/promote` with `asPackageVersion` (FB11): a data shadow variant whose every
|
|
5034
|
+
* part is linked to one package becomes that package's next version of its live agent. Its parts,
|
|
5035
|
+
* as an export reads them, are imported onto the live agent with the import's rules and relinked;
|
|
5036
|
+
* the live agent keeps its name and chat offer, an agent in code its run settings in code. The
|
|
5037
|
+
* variant leaves first, with its links, so its trigger ids are free, and the live agent leaves the
|
|
5038
|
+
* package it belonged to; a refused part puts everything back and answers the import's
|
|
5039
|
+
* `409 assistant-package-refused`. A part without a link answers `404 assistant-package-not-linked`.
|
|
5040
|
+
*/
|
|
5041
|
+
promoteAsPackageVersion(variantId, body) {
|
|
5042
|
+
const listed = this.context.call('GET', 'admin/agents');
|
|
5043
|
+
if (listed.status !== 200) {
|
|
5044
|
+
return listed;
|
|
5045
|
+
}
|
|
5046
|
+
const request = (body ?? {});
|
|
5047
|
+
if (typeof request.expectedVersion !== 'number') {
|
|
5048
|
+
return mockFailure('assistant-validation', { expectedVersion: ['required'] });
|
|
5049
|
+
}
|
|
5050
|
+
if (request.moveSkills === true) {
|
|
5051
|
+
return mockFailure('assistant-validation', { moveSkills: ['not-allowed'] });
|
|
5052
|
+
}
|
|
5053
|
+
const variantRead = this.read(variantId);
|
|
5054
|
+
if (!variantRead) {
|
|
5055
|
+
return mockFailure('assistant-agent-not-found');
|
|
5056
|
+
}
|
|
5057
|
+
const variant = variantRead.agent;
|
|
5058
|
+
if (variant.source !== 'admin' || !variant.shadowOf) {
|
|
5059
|
+
return mockFailure('assistant-not-shadow-variant');
|
|
5060
|
+
}
|
|
5061
|
+
const liveRead = this.read(variant.shadowOf);
|
|
5062
|
+
if (!liveRead) {
|
|
5063
|
+
return mockFailure('assistant-agent-not-found');
|
|
5064
|
+
}
|
|
5065
|
+
const live = liveRead.agent;
|
|
5066
|
+
if (request.expectedVersion !== live.version
|
|
5067
|
+
|| (typeof request.expectedVariantVersion === 'number' && request.expectedVariantVersion !== variant.version)) {
|
|
5068
|
+
return mockFailure('assistant-version-conflict');
|
|
5069
|
+
}
|
|
5070
|
+
// Every part of the variant is linked to its package.
|
|
5071
|
+
const link = this.links.get(variantId);
|
|
5072
|
+
const parts = this.packageAgentOf(variantRead);
|
|
5073
|
+
if (!link || [...partsOf(parts).keys()].some(part => !link.parts.has(part))) {
|
|
5074
|
+
return mockFailure('assistant-package-not-linked');
|
|
5075
|
+
}
|
|
5076
|
+
const inCode = live.source === 'code';
|
|
5077
|
+
const carried = parts.definition;
|
|
5078
|
+
const definition = !carried
|
|
5079
|
+
? null
|
|
5080
|
+
: inCode
|
|
5081
|
+
? {
|
|
5082
|
+
description: carried.description,
|
|
5083
|
+
instructions: carried.instructions,
|
|
5084
|
+
toolSelectors: carried.toolSelectors,
|
|
5085
|
+
requiredPolicy: carried.requiredPolicy,
|
|
5086
|
+
autonomy: carried.autonomy
|
|
5087
|
+
}
|
|
5088
|
+
: { ...carried, displayName: live.displayName, offeredInChat: live.offeredInChat !== false, shadowOf: null, startsOff: null, startsInShadow: null };
|
|
5089
|
+
const locks = Object.fromEntries([...link.parts]
|
|
5090
|
+
.filter(([part, partLink]) => partLink.lockedFields.length > 0 && (part !== 'runSettings' || !inCode))
|
|
5091
|
+
.map(([part, partLink]) => [part, [...partLink.lockedFields]]));
|
|
5092
|
+
const agent = withoutNulls({
|
|
5093
|
+
...parts,
|
|
5094
|
+
id: live.id,
|
|
5095
|
+
source: live.source,
|
|
5096
|
+
definition,
|
|
5097
|
+
runSettings: inCode ? null : parts.runSettings,
|
|
5098
|
+
groups: null,
|
|
5099
|
+
hashes: null,
|
|
5100
|
+
locks: Object.keys(locks).length > 0 ? locks : null
|
|
5101
|
+
});
|
|
5102
|
+
const agentPackage = {
|
|
5103
|
+
format: NH_ASSISTANT_AGENT_PACKAGE_FORMAT,
|
|
5104
|
+
formatVersion: NH_ASSISTANT_AGENT_PACKAGE_FORMAT_VERSION,
|
|
5105
|
+
package: { id: link.packageId, version: link.packageVersion },
|
|
5106
|
+
agents: [agent]
|
|
5107
|
+
};
|
|
5108
|
+
// The live agent's technical account stays; without one it takes the variant's.
|
|
5109
|
+
const account = liveRead.configuration?.runSettings?.serviceAccountActorId
|
|
5110
|
+
?? variantRead.configuration?.runSettings?.serviceAccountActorId
|
|
5111
|
+
?? null;
|
|
5112
|
+
// All of it or nothing: the variant leaves first, and the package's other agents stay linked.
|
|
5113
|
+
const restore = [this.context.snapshot(), this.snapshot()];
|
|
5114
|
+
const others = [...this.links].filter(([agentId, item]) => item.packageId === link.packageId && agentId !== variantId);
|
|
5115
|
+
this.links.delete(variantId);
|
|
5116
|
+
const deleted = this.context.call('DELETE', `admin/agents/${encodeURIComponent(variantId)}`);
|
|
5117
|
+
if (deleted.status >= 300) {
|
|
5118
|
+
restore.forEach(put => put());
|
|
5119
|
+
return deleted;
|
|
5120
|
+
}
|
|
5121
|
+
for (const [agentId] of others) {
|
|
5122
|
+
this.links.delete(agentId);
|
|
5123
|
+
}
|
|
5124
|
+
this.links.delete(live.id);
|
|
5125
|
+
for (const installed of this.installed.values()) {
|
|
5126
|
+
installed.agents = installed.agents.filter(agentId => agentId !== live.id && agentId !== variantId);
|
|
5127
|
+
}
|
|
5128
|
+
const imported = this.importPackage({
|
|
5129
|
+
package: agentPackage,
|
|
5130
|
+
options: { relink: [{ agentId: live.id }], serviceAccounts: account ? { [live.id]: account } : null }
|
|
5131
|
+
}, true);
|
|
5132
|
+
if (imported.status !== 200) {
|
|
5133
|
+
restore.forEach(put => put());
|
|
5134
|
+
return imported;
|
|
5135
|
+
}
|
|
5136
|
+
for (const [agentId, item] of others) {
|
|
5137
|
+
this.links.set(agentId, item);
|
|
5138
|
+
}
|
|
5139
|
+
this.installed.set(link.packageId, {
|
|
5140
|
+
version: link.packageVersion,
|
|
5141
|
+
agents: [...others.map(([agentId]) => agentId), live.id].sort()
|
|
5142
|
+
});
|
|
5143
|
+
const result = imported.body;
|
|
5144
|
+
const promoted = this.read(live.id).agent;
|
|
5145
|
+
return {
|
|
5146
|
+
status: 200,
|
|
5147
|
+
body: {
|
|
5148
|
+
agent: this.withAgentPackage(promoted),
|
|
5149
|
+
variantId,
|
|
5150
|
+
movedSkills: result.parts
|
|
5151
|
+
.filter(part => part.part.startsWith('skill:') && part.outcome !== 'refused' && part.outcome !== 'left-behind' && part.outcome !== 'kept-local')
|
|
5152
|
+
.map(part => part.part.slice('skill:'.length))
|
|
5153
|
+
.sort(),
|
|
5154
|
+
package: result
|
|
5155
|
+
}
|
|
5156
|
+
};
|
|
5157
|
+
}
|
|
4871
5158
|
/**
|
|
4872
5159
|
* Runs a save of the administration under the package locks: a save may keep a locked field's value
|
|
4873
5160
|
* or take the package's, never another; such a change is undone and refused with `{field}: locked`.
|
|
@@ -4975,6 +5262,22 @@ class NhAssistantMockAgentPackages {
|
|
|
4975
5262
|
return { ...result, body: this.withLink(agentId, `metadata:${id}`, body) };
|
|
4976
5263
|
}
|
|
4977
5264
|
return { ...result, body: body.map(entry => this.withLink(agentId, `metadata:${entry.name}`, entry)) };
|
|
5265
|
+
case 'shadow-variants': {
|
|
5266
|
+
// A data variant names the package its parts are linked to.
|
|
5267
|
+
const listing = body;
|
|
5268
|
+
return {
|
|
5269
|
+
...result,
|
|
5270
|
+
body: {
|
|
5271
|
+
...listing,
|
|
5272
|
+
variants: listing.variants.map(variant => {
|
|
5273
|
+
const link = this.links.get(variant.agentId);
|
|
5274
|
+
return variant.source === 'data' && link && link.parts.size > 0
|
|
5275
|
+
? { ...variant, packageId: link.packageId, packageVersion: link.packageVersion }
|
|
5276
|
+
: variant;
|
|
5277
|
+
})
|
|
5278
|
+
}
|
|
5279
|
+
};
|
|
5280
|
+
}
|
|
4978
5281
|
default:
|
|
4979
5282
|
return result;
|
|
4980
5283
|
}
|
|
@@ -6951,8 +7254,9 @@ class NhAssistantMockAgentRuns {
|
|
|
6951
7254
|
}
|
|
6952
7255
|
/**
|
|
6953
7256
|
* `POST runs/{id}/retry`: resumes a failed or cancelled run (`queued`), or restarts the agent in the
|
|
6954
|
-
* same thread like a start by hand; needs the approver role on the thread
|
|
6955
|
-
* attempt ends at once as the
|
|
7257
|
+
* same thread like a start by hand; needs the approver role on the thread, and a restart also passes
|
|
7258
|
+
* the start checks of `POST threads/start`. The mock's resumed attempt ends at once as the
|
|
7259
|
+
* scenario's `runNow` says.
|
|
6956
7260
|
*/
|
|
6957
7261
|
retry(runId, body, language) {
|
|
6958
7262
|
const mode = body?.mode ?? 'resume';
|
|
@@ -6978,7 +7282,17 @@ class NhAssistantMockAgentRuns {
|
|
|
6978
7282
|
return mockFailure('assistant-agent-not-found');
|
|
6979
7283
|
}
|
|
6980
7284
|
if (mode === 'restart') {
|
|
6981
|
-
|
|
7285
|
+
// A restart starts the agent again for the caller: the rule of a start by hand decides, with the
|
|
7286
|
+
// caller's live role.
|
|
7287
|
+
const subject = this.subjects.describe(found.thread.subject.subjectType, found.thread.subject.subjectId, language, this.context.administers());
|
|
7288
|
+
if (!subject) {
|
|
7289
|
+
return mockFailure('assistant-subject-access-denied');
|
|
7290
|
+
}
|
|
7291
|
+
const refusal = this.refusal(agent, subject);
|
|
7292
|
+
if (refusal) {
|
|
7293
|
+
return mockFailure(refusal);
|
|
7294
|
+
}
|
|
7295
|
+
return this.startNow(agent, subject);
|
|
6982
7296
|
}
|
|
6983
7297
|
const runNow = this.scenario.runNow?.[agent.id] ?? {};
|
|
6984
7298
|
const status = runNow.status ?? 'completed';
|
|
@@ -7964,7 +8278,8 @@ class NhAssistantMockProposals {
|
|
|
7964
8278
|
parent: item.parent ?? null,
|
|
7965
8279
|
canGiveFeedback: false,
|
|
7966
8280
|
consultationId: item.consultationId ?? null,
|
|
7967
|
-
consultationRoute: route
|
|
8281
|
+
consultationRoute: route,
|
|
8282
|
+
...(item.supersededByApprovalId ? { supersededByApprovalId: item.supersededByApprovalId } : {})
|
|
7968
8283
|
},
|
|
7969
8284
|
divisionId: item.divisionId ?? null,
|
|
7970
8285
|
canDecide,
|
|
@@ -8830,7 +9145,10 @@ class NhAssistantMockBackend {
|
|
|
8830
9145
|
if (filesCode) {
|
|
8831
9146
|
return this.error(filesCode);
|
|
8832
9147
|
}
|
|
8833
|
-
|
|
9148
|
+
// Like the server: a question or an approval that passed its lifetime closes as expired, and the
|
|
9149
|
+
// message starts its turn (CS9).
|
|
9150
|
+
this.closeExpiredWait(conversation);
|
|
9151
|
+
if (!acceptsMessage(this.conversations.get(conversation.id)?.status ?? conversation.status)) {
|
|
8834
9152
|
return this.empty(409);
|
|
8835
9153
|
}
|
|
8836
9154
|
// Like the server: an invalid page context is dropped, never a 400.
|
|
@@ -9005,6 +9323,28 @@ class NhAssistantMockBackend {
|
|
|
9005
9323
|
await this.playSteps(conversation.id, assistantMessageId, steps, emit);
|
|
9006
9324
|
});
|
|
9007
9325
|
}
|
|
9326
|
+
/**
|
|
9327
|
+
* Closes the question or the approval a chat waits for once its lifetime passed, as a late answer or
|
|
9328
|
+
* decision closes it on the server; one that is still valid keeps the chat busy. Agent threads keep
|
|
9329
|
+
* their own rules.
|
|
9330
|
+
*/
|
|
9331
|
+
closeExpiredWait(conversation) {
|
|
9332
|
+
if (conversation.status !== 'waiting-for-approval' || conversation.kind === 'thread') {
|
|
9333
|
+
return;
|
|
9334
|
+
}
|
|
9335
|
+
const now = Date.now();
|
|
9336
|
+
const question = this.pendingQuestions.get(conversation.id);
|
|
9337
|
+
if (question && Date.parse(question.question.expiresAt) <= now) {
|
|
9338
|
+
this.pendingQuestions.delete(conversation.id);
|
|
9339
|
+
this.update(conversation.id, current => withClosedQuestion(current, question.question.questionId, 'expired'));
|
|
9340
|
+
return;
|
|
9341
|
+
}
|
|
9342
|
+
const pending = this.pendingApprovals.get(conversation.id);
|
|
9343
|
+
if (pending && Date.parse(pending.approval.expiresAt) <= now) {
|
|
9344
|
+
this.pendingApprovals.delete(conversation.id);
|
|
9345
|
+
this.update(conversation.id, current => withExpiredApproval(current, pending.approval.approvalId, pending.invocationId));
|
|
9346
|
+
}
|
|
9347
|
+
}
|
|
9008
9348
|
cancel(conversation) {
|
|
9009
9349
|
const run = this.runs.get(conversation.id);
|
|
9010
9350
|
if (run) {
|
|
@@ -9589,6 +9929,27 @@ function withClosedQuestion(conversation, questionId, status) {
|
|
|
9589
9929
|
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(closed) }))
|
|
9590
9930
|
};
|
|
9591
9931
|
}
|
|
9932
|
+
/**
|
|
9933
|
+
* An approval that passed its lifetime, as the server closes it when the next message arrives: it
|
|
9934
|
+
* expired, its call failed with `assistant-approval-expired`, and the conversation takes the message.
|
|
9935
|
+
*/
|
|
9936
|
+
function withExpiredApproval(conversation, approvalId, invocationId) {
|
|
9937
|
+
const expired = (part) => {
|
|
9938
|
+
if (part.type === 'approval' && part.approvalId === approvalId) {
|
|
9939
|
+
return { ...part, status: 'expired' };
|
|
9940
|
+
}
|
|
9941
|
+
if (part.type === 'tool-call' && part.invocationId === invocationId) {
|
|
9942
|
+
return { ...part, status: 'failed', resultCode: 'assistant-approval-expired' };
|
|
9943
|
+
}
|
|
9944
|
+
return part;
|
|
9945
|
+
};
|
|
9946
|
+
return {
|
|
9947
|
+
...conversation,
|
|
9948
|
+
status: 'idle',
|
|
9949
|
+
pendingApproval: null,
|
|
9950
|
+
messages: conversation.messages.map(message => ({ ...message, parts: message.parts.map(expired) }))
|
|
9951
|
+
};
|
|
9952
|
+
}
|
|
9592
9953
|
/** Like the server: a new message starts in an idle conversation or after a failed turn. */
|
|
9593
9954
|
function acceptsMessage(status) {
|
|
9594
9955
|
return status === 'idle' || status === 'failed';
|