@memberjunction/server 6.1.3 → 6.2.0-edge.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/dist/.tsbuildinfo +1 -1
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +255 -4
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1256 -0
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +26 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +59 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +37 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +98 -8
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +5 -4
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchEntitiesResolver.js +3 -1
- package/dist/resolvers/SearchEntitiesResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +53 -4
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/services/MJStorageBlobStore.d.ts +41 -0
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -0
- package/dist/services/MJStorageBlobStore.js +160 -0
- package/dist/services/MJStorageBlobStore.js.map +1 -0
- package/dist/util.d.ts +6 -3
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +6 -3
- package/dist/util.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +229 -0
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/SearchEntitiesResolver.readOnlyFallback.test.ts +78 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +290 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/generated/graphql-schemas/__mj.ts +896 -5
- package/src/generic/ResolverBase.ts +61 -0
- package/src/index.ts +14 -1
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +123 -10
- package/src/resolvers/RunAIAgentResolver.ts +5 -4
- package/src/resolvers/SearchEntitiesResolver.ts +3 -1
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/TransactionGroupResolver.ts +57 -4
- package/src/services/MJStorageBlobStore.ts +209 -0
- package/src/util.ts +6 -3
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
EntitySaveOptions,
|
|
11
11
|
IMetadataProvider,
|
|
12
12
|
IRunViewProvider,
|
|
13
|
+
KeyValuePair,
|
|
13
14
|
LogDebug,
|
|
14
15
|
LogError,
|
|
15
16
|
LogStatus,
|
|
@@ -1521,6 +1522,62 @@ export class ResolverBase {
|
|
|
1521
1522
|
}
|
|
1522
1523
|
}
|
|
1523
1524
|
|
|
1525
|
+
/**
|
|
1526
|
+
* IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
|
|
1527
|
+
* now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
|
|
1528
|
+
* primary key, because in IS-A the shared key is the relationship and the child has no key of
|
|
1529
|
+
* its own to mint. `NewRecord()` has just reset the whole chain to "new", so left alone the
|
|
1530
|
+
* parent would save as a CREATE: a second copy of a row that already exists (a unique-constraint
|
|
1531
|
+
* violation at best, a silently duplicated parent at worst).
|
|
1532
|
+
*
|
|
1533
|
+
* When a child create carries a complete primary key, bind the new child to the existing parent
|
|
1534
|
+
* row with `AttachToParent` (#3825): it loads the parent chain, so the parent saves as an UPDATE
|
|
1535
|
+
* and only the child is INSERTed. A key that matches no row leaves the fresh chain exactly as
|
|
1536
|
+
* `NewRecord()` built it — the ordinary whole-chain create. A non-IS-A entity, or a create with
|
|
1537
|
+
* no key, never gets past the guards.
|
|
1538
|
+
*
|
|
1539
|
+
* @returns true when the child was attached to an existing parent row.
|
|
1540
|
+
*/
|
|
1541
|
+
protected async attachToExistingParentIfPromotion(entityObject: BaseEntity, input: Record<string, unknown>): Promise<boolean> {
|
|
1542
|
+
if (!entityObject.EntityInfo.IsChildType) {
|
|
1543
|
+
return false;
|
|
1544
|
+
}
|
|
1545
|
+
const key = this.primaryKeyFromInput(entityObject.EntityInfo, input);
|
|
1546
|
+
if (!key) {
|
|
1547
|
+
return false;
|
|
1548
|
+
}
|
|
1549
|
+
const attached = await entityObject.AttachToParent(key);
|
|
1550
|
+
if (!attached) {
|
|
1551
|
+
// No parent row under that key: this is a whole-chain create on the CALLER's key (the
|
|
1552
|
+
// client mints the shared key at the root and sends it, so honoring it keeps the client's
|
|
1553
|
+
// in-memory chain and the stored rows on one key). AttachToParent restored the fresh chain,
|
|
1554
|
+
// but the root's ReadOnly key slot is no longer writable — a later SetMany would silently
|
|
1555
|
+
// keep the server-minted value — so re-seed the chain with the supplied key explicitly.
|
|
1556
|
+
entityObject.NewRecord(key);
|
|
1557
|
+
}
|
|
1558
|
+
return attached;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* The entity's primary key as supplied on a mutation input, or null when any part of it is
|
|
1563
|
+
* missing. Composite keys are all-or-nothing: a half-specified key identifies nothing. An empty
|
|
1564
|
+
* string counts as missing, the same as null — it is never a valid key value, and a GUID or
|
|
1565
|
+
* string key that is "" identifies no row, so the create proceeds as a whole-chain create rather
|
|
1566
|
+
* than failing the lookup. A malformed non-empty key (e.g. a non-GUID for a uniqueidentifier)
|
|
1567
|
+
* is NOT swallowed: the load throws and the mutation fails loudly.
|
|
1568
|
+
*/
|
|
1569
|
+
protected primaryKeyFromInput(entityInfo: EntityInfo, input: Record<string, unknown>): CompositeKey | null {
|
|
1570
|
+
const pairs: KeyValuePair[] = [];
|
|
1571
|
+
for (const pk of entityInfo.PrimaryKeys) {
|
|
1572
|
+
const value = input[pk.Name] ?? input[pk.CodeName];
|
|
1573
|
+
if (value === null || value === undefined || value === '') {
|
|
1574
|
+
return null;
|
|
1575
|
+
}
|
|
1576
|
+
pairs.push(new KeyValuePair(pk.Name, value));
|
|
1577
|
+
}
|
|
1578
|
+
return pairs.length > 0 ? CompositeKey.FromKeyValuePairs(pairs) : null;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1524
1581
|
protected async CreateRecord(entityName: string, input: any, provider: DatabaseProviderBase, userPayload: UserPayload, pubSub: PubSubEngine) {
|
|
1525
1582
|
// Check API key scope authorization for entity create operations
|
|
1526
1583
|
await this.CheckAPIKeyScopeAuthorization('entity:create', entityName, userPayload);
|
|
@@ -1538,6 +1595,10 @@ export class ResolverBase {
|
|
|
1538
1595
|
for (const key of Object.keys(input)) {
|
|
1539
1596
|
if (key !== 'RestoreContext___') fieldsForSet[key] = input[key];
|
|
1540
1597
|
}
|
|
1598
|
+
// IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
|
|
1599
|
+
// assignments, so values the client sent for parent fields land on the loaded parent
|
|
1600
|
+
// (as an update) instead of being wiped by the load.
|
|
1601
|
+
await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
|
|
1541
1602
|
entityObject.SetMany(fieldsForSet);
|
|
1542
1603
|
|
|
1543
1604
|
// Reconstruct the client-side restore context, if any, on this server
|
package/src/index.ts
CHANGED
|
@@ -67,6 +67,8 @@ import { RegisterRSUProgressBridge } from './integration/RSUProgressBridge.js';
|
|
|
67
67
|
import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
|
|
68
68
|
import { SessionJanitor } from './agentSessions/index.js';
|
|
69
69
|
import { StartTaskGraphDispatcher } from './services/StartTaskGraphDispatcher.js';
|
|
70
|
+
import { GetAttachmentService } from '@memberjunction/aiengine';
|
|
71
|
+
import { MJStorageBlobStore } from './services/MJStorageBlobStore.js';
|
|
70
72
|
import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
|
|
71
73
|
import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
|
|
72
74
|
import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
|
|
@@ -281,6 +283,17 @@ function resolveServerVersion(): string | undefined {
|
|
|
281
283
|
}
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
// Bind MJStorage as the conversation-attachment blob store. The attachment service itself no longer
|
|
287
|
+
// imports `@memberjunction/storage` — that dependency made it unusable from any browser or React
|
|
288
|
+
// Native client, which is why the same attachment rules had been reimplemented three times.
|
|
289
|
+
//
|
|
290
|
+
// This runs at module load, not inside `serve()`, so that merely importing MJServer is enough: any
|
|
291
|
+
// entry point that reaches the attachment service — a resolver under test, a script, a worker that
|
|
292
|
+
// never calls `serve()` — finds storage already bound rather than degrading to "storage is not
|
|
293
|
+
// available on this host". The store is stateless and configures `FileStorageEngine` on use, so
|
|
294
|
+
// there is no ordering hazard in binding this early.
|
|
295
|
+
GetAttachmentService().BlobStore = new MJStorageBlobStore();
|
|
296
|
+
|
|
284
297
|
export const serve = async (resolverPaths: Array<string>, app: Application = createApp(), options?: MJServerOptions): Promise<void> => {
|
|
285
298
|
const t0 = performance.now();
|
|
286
299
|
// Level-gated startup logger. Resolves verbosity from telemetry.level (single
|
|
@@ -307,7 +320,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
307
320
|
console.log({ combinedResolverPaths, paths, cwd: process.cwd() });
|
|
308
321
|
}
|
|
309
322
|
|
|
310
|
-
|
|
323
|
+
const setupComplete$ = new ReplaySubject(1);
|
|
311
324
|
const dbType = getDbType();
|
|
312
325
|
const dataSources: DataSourceInfo[] = [];
|
|
313
326
|
|
|
@@ -5,7 +5,7 @@ import { MJEntityDocumentEntity, MJVectorIndexEntity, MJVectorDatabaseEntity } f
|
|
|
5
5
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
6
6
|
import { GetAIAPIKey } from '@memberjunction/ai';
|
|
7
7
|
import { VectorDBBase } from '@memberjunction/ai-vectordb';
|
|
8
|
-
import { MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
8
|
+
import { EscapeSQLString, MJGlobal, UUIDsEqual } from '@memberjunction/global';
|
|
9
9
|
|
|
10
10
|
/* ───── GraphQL types ───── */
|
|
11
11
|
|
|
@@ -144,7 +144,7 @@ export class FetchEntityVectorsResolver extends ResolverBase {
|
|
|
144
144
|
const rv = new RunView();
|
|
145
145
|
const result = await rv.RunView<MJEntityDocumentEntity>({
|
|
146
146
|
EntityName: 'MJ: Entity Documents',
|
|
147
|
-
ExtraFilter: `ID='${entityDocumentID}'`,
|
|
147
|
+
ExtraFilter: `ID='${EscapeSQLString(entityDocumentID)}'`,
|
|
148
148
|
ResultType: 'entity_object',
|
|
149
149
|
}, contextUser);
|
|
150
150
|
|
|
@@ -6,6 +6,7 @@ import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEnt
|
|
|
6
6
|
import { GetReadWriteProvider } from '../util.js';
|
|
7
7
|
import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
|
|
8
8
|
import { MJQueryEntityServer } from '@memberjunction/core-entities-server';
|
|
9
|
+
import { EscapeSQLString } from '@memberjunction/global';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Minimal shape of a query row returned by RunView lookups (plain object, not entity instance).
|
|
@@ -493,7 +494,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
493
494
|
// now make sure there is NO existing query by the same name in the specified category
|
|
494
495
|
const existingQueryResult = await provider.RunView({
|
|
495
496
|
EntityName: 'MJ: Queries',
|
|
496
|
-
ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
|
|
497
|
+
ExtraFilter: `Name='${EscapeSQLString(input.Name)}' AND CategoryID='${EscapeSQLString(finalCategoryID)}'`
|
|
497
498
|
}, context.userPayload.userRecord);
|
|
498
499
|
if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
|
|
499
500
|
// we have a match! Let's return an error
|
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
deleteRealtimeRecordingSegments,
|
|
58
58
|
} from '@memberjunction/ai-agents';
|
|
59
59
|
import { AgentExecutionProgressCallback, MJAIAgentEntityExtended, AppContextSnapshot } from '@memberjunction/ai-core-plus';
|
|
60
|
-
import { RealtimeToolDefinition } from '@memberjunction/ai';
|
|
60
|
+
import { ChatMessage, RealtimeToolDefinition } from '@memberjunction/ai';
|
|
61
61
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
62
62
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
63
63
|
import { GetReadWriteProvider } from '../util.js';
|
|
@@ -136,6 +136,11 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
|
|
|
136
136
|
/** Maximum prior-session chain legs walked when hydrating a resumed session's transcript. */
|
|
137
137
|
const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
|
|
138
138
|
|
|
139
|
+
/** Maximum TEXT-conversation turns hydrated into a voice session's companion prompt (newest kept). */
|
|
140
|
+
const MAX_CONVERSATION_HISTORY_TURNS = 30;
|
|
141
|
+
/** Maximum TEXT-conversation CHARS hydrated into a voice session's companion prompt (oldest dropped). */
|
|
142
|
+
const MAX_CONVERSATION_HISTORY_CHARS = 8_000;
|
|
143
|
+
|
|
139
144
|
/**
|
|
140
145
|
* Authoritative shape persisted in `AIAgentSession.Config_` for a client-direct voice session.
|
|
141
146
|
* The target agent id is stored here at start and read back on every relay — the browser never
|
|
@@ -486,10 +491,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
486
491
|
// Best-effort model-context hydration: the PRIOR session chain's transcript (ownership-
|
|
487
492
|
// checked, capped) is framed into the system prompt so the model REMEMBERS the last leg.
|
|
488
493
|
// Strictly tolerant — any problem yields no hydration, never a failed start.
|
|
489
|
-
const
|
|
494
|
+
const prior = await this.loadPriorTranscript(lastSessionId, contextUser, provider);
|
|
495
|
+
// ...and the TEXT conversation the call is being started from, so a voice session opened
|
|
496
|
+
// mid-thread knows what was already typed. The legs above are excluded: they are already
|
|
497
|
+
// framed as the resumed transcript, and injecting the same turns twice puts two accounts of
|
|
498
|
+
// the same exchange in one prompt.
|
|
499
|
+
const conversationMessages = await this.loadConversationHistory(
|
|
500
|
+
conversationId, prior?.LegIDs ?? [], contextUser, provider,
|
|
501
|
+
);
|
|
490
502
|
const result = await this.prepareClientSessionOrClose(
|
|
491
|
-
session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools,
|
|
492
|
-
configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson),
|
|
503
|
+
session, coAgentID, effectiveTargetId, contextUser, provider, preferredModelId, clientTools, prior?.Text,
|
|
504
|
+
configOverridesJson, maxSessionSeconds, applicationId, this.parseAppContext(appContextJson), conversationMessages,
|
|
493
505
|
);
|
|
494
506
|
// Best-effort restore of the PRIOR session's persisted channel states (e.g. the whiteboard
|
|
495
507
|
// board). Strictly tolerant — any problem yields a null field, never a failed start.
|
|
@@ -1684,6 +1696,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1684
1696
|
* @param priorTranscript Optional capped, role-tagged transcript of the PRIOR session chain
|
|
1685
1697
|
* (from {@link loadPriorTranscript}) — the service frames it into the system prompt so a
|
|
1686
1698
|
* resumed session remembers the previous leg(s).
|
|
1699
|
+
* @param conversationMessages Optional capped history of the TEXT conversation this session is
|
|
1700
|
+
* starting from (from {@link loadConversationHistory}) — framed as "Conversation so far".
|
|
1687
1701
|
*/
|
|
1688
1702
|
private async prepareClientSessionOrClose(
|
|
1689
1703
|
session: MJAIAgentSessionEntity,
|
|
@@ -1698,6 +1712,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1698
1712
|
maxSessionSeconds?: number,
|
|
1699
1713
|
applicationId?: string,
|
|
1700
1714
|
appContext?: AppContextSnapshot,
|
|
1715
|
+
conversationMessages?: ChatMessage[],
|
|
1701
1716
|
): Promise<StartRealtimeClientSessionResult> {
|
|
1702
1717
|
const prep = await this.clientSessionService.PrepareClientSession(
|
|
1703
1718
|
{
|
|
@@ -1708,10 +1723,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1708
1723
|
// Server-authoritative voice duration cap (widget guests) — bounds the driver session
|
|
1709
1724
|
// where supported; the janitor enforces the session deadline regardless.
|
|
1710
1725
|
MaxSessionSeconds: maxSessionSeconds,
|
|
1711
|
-
//
|
|
1712
|
-
//
|
|
1713
|
-
//
|
|
1714
|
-
ConversationMessages: [],
|
|
1726
|
+
// The TEXT conversation this call was started from, so the co-agent opens knowing
|
|
1727
|
+
// what the user already typed rather than asking them to say it again. Hydrated by
|
|
1728
|
+
// `loadConversationHistory`; empty when there is no conversation or the read failed.
|
|
1729
|
+
ConversationMessages: conversationMessages ?? [],
|
|
1715
1730
|
UserID: contextUser.ID,
|
|
1716
1731
|
PreferredModelID: preferredModelId,
|
|
1717
1732
|
// Client-declared, CLIENT-EXECUTED UI tools (see the mutation's SECURITY NOTE) —
|
|
@@ -2005,7 +2020,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
2005
2020
|
lastSessionId: string | undefined,
|
|
2006
2021
|
contextUser: UserInfo,
|
|
2007
2022
|
provider: IMetadataProvider,
|
|
2008
|
-
): Promise<string | undefined> {
|
|
2023
|
+
): Promise<{ Text: string; LegIDs: string[] } | undefined> {
|
|
2009
2024
|
if (!lastSessionId) {
|
|
2010
2025
|
return undefined;
|
|
2011
2026
|
}
|
|
@@ -2016,7 +2031,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
2016
2031
|
}
|
|
2017
2032
|
const turns = await this.loadChainTranscriptTurns(legIDs, contextUser, provider);
|
|
2018
2033
|
const lines = this.capTranscriptLines(turns);
|
|
2019
|
-
|
|
2034
|
+
// The leg ids travel with the text because the CONVERSATION history hydrated alongside
|
|
2035
|
+
// this would otherwise re-inject the same turns: a voice turn is persisted as an
|
|
2036
|
+
// ordinary Conversation Detail, so these legs are part of the conversation too.
|
|
2037
|
+
return lines.length > 0 ? { Text: lines.join('\n'), LegIDs: legIDs } : undefined;
|
|
2020
2038
|
} catch (error) {
|
|
2021
2039
|
LogError(
|
|
2022
2040
|
`StartRealtimeClientSession: prior-transcript hydration failed for session ${lastSessionId}: ${(error as Error).message}`,
|
|
@@ -2025,6 +2043,101 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
2025
2043
|
}
|
|
2026
2044
|
}
|
|
2027
2045
|
|
|
2046
|
+
/**
|
|
2047
|
+
* Loads the conversation this voice session is starting from, as `ChatMessage[]` for the
|
|
2048
|
+
* co-agent's companion prompt.
|
|
2049
|
+
*
|
|
2050
|
+
* This is the half of the context flow that was missing. Voice turns have always been
|
|
2051
|
+
* persisted as `MJ: Conversation Details`, so anything SAID reaches a later text turn — but
|
|
2052
|
+
* nothing TYPED reached a voice session, which opened with no idea what the thread was about.
|
|
2053
|
+
* The symptom is not an error; it is the agent asking the user to repeat something they just
|
|
2054
|
+
* wrote, which is exactly when voice is least forgivable.
|
|
2055
|
+
*
|
|
2056
|
+
* Caps mirror the resume path (newest {@link MAX_CONVERSATION_HISTORY_TURNS} turns, then a
|
|
2057
|
+
* {@link MAX_CONVERSATION_HISTORY_CHARS} budget with the oldest dropped first) so the freshest
|
|
2058
|
+
* context always survives.
|
|
2059
|
+
*
|
|
2060
|
+
* Session-stamped rows are KEPT unless `coveredSessionIDs` names them: a call the user had last
|
|
2061
|
+
* week is part of this conversation and the model should know it. Only the legs already framed
|
|
2062
|
+
* as the resumed transcript are removed, so the prompt never carries two accounts of one
|
|
2063
|
+
* exchange.
|
|
2064
|
+
*
|
|
2065
|
+
* Strictly tolerant — no conversation, a failed read, or any throw yields `[]`, never a failed
|
|
2066
|
+
* session start. The call still works; it just starts cold.
|
|
2067
|
+
*
|
|
2068
|
+
* @param conversationId The conversation the session is bound to, when there is one.
|
|
2069
|
+
* @param coveredSessionIDs Session ids already framed via `PriorTranscript`.
|
|
2070
|
+
* @param contextUser The calling user (RunView scope — the read is row-level-secured).
|
|
2071
|
+
* @param provider The request-scoped metadata provider.
|
|
2072
|
+
* @returns The capped history, oldest first.
|
|
2073
|
+
*/
|
|
2074
|
+
private async loadConversationHistory(
|
|
2075
|
+
conversationId: string | undefined,
|
|
2076
|
+
coveredSessionIDs: string[],
|
|
2077
|
+
contextUser: UserInfo,
|
|
2078
|
+
provider: IMetadataProvider,
|
|
2079
|
+
): Promise<ChatMessage[]> {
|
|
2080
|
+
if (!conversationId) {
|
|
2081
|
+
return [];
|
|
2082
|
+
}
|
|
2083
|
+
try {
|
|
2084
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
2085
|
+
const result = await rv.RunView<{
|
|
2086
|
+
Role: string;
|
|
2087
|
+
Message: string | null;
|
|
2088
|
+
HiddenToUser: boolean;
|
|
2089
|
+
AgentSessionID: string | null;
|
|
2090
|
+
}>(
|
|
2091
|
+
{
|
|
2092
|
+
EntityName: CONVERSATION_DETAIL_ENTITY,
|
|
2093
|
+
ExtraFilter: `ConversationID='${conversationId.replace(/'/g, "''")}'`,
|
|
2094
|
+
Fields: ['ID', 'Role', 'Message', 'HiddenToUser', 'AgentSessionID', '__mj_CreatedAt'],
|
|
2095
|
+
OrderBy: '__mj_CreatedAt ASC',
|
|
2096
|
+
ResultType: 'simple',
|
|
2097
|
+
},
|
|
2098
|
+
contextUser,
|
|
2099
|
+
);
|
|
2100
|
+
if (!result.Success) {
|
|
2101
|
+
LogError(`StartRealtimeClientSession: conversation-history query failed: ${result.ErrorMessage}`);
|
|
2102
|
+
return [];
|
|
2103
|
+
}
|
|
2104
|
+
const covered = new Set(coveredSessionIDs.map((id) => id.trim().toLowerCase()));
|
|
2105
|
+
const turns = (result.Results ?? []).filter(
|
|
2106
|
+
(row) =>
|
|
2107
|
+
!row.HiddenToUser &&
|
|
2108
|
+
(row.Role === 'User' || row.Role === 'AI') &&
|
|
2109
|
+
typeof row.Message === 'string' &&
|
|
2110
|
+
row.Message.trim().length > 0 &&
|
|
2111
|
+
!covered.has((row.AgentSessionID ?? '').trim().toLowerCase()),
|
|
2112
|
+
);
|
|
2113
|
+
return this.capConversationHistory(turns);
|
|
2114
|
+
} catch (error) {
|
|
2115
|
+
LogError(
|
|
2116
|
+
`StartRealtimeClientSession: conversation-history hydration failed for conversation ${conversationId}: ${(error as Error).message}`,
|
|
2117
|
+
);
|
|
2118
|
+
return [];
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
/**
|
|
2123
|
+
* Applies the history caps and maps to `ChatMessage[]`: the newest
|
|
2124
|
+
* {@link MAX_CONVERSATION_HISTORY_TURNS} turns, then a total budget of
|
|
2125
|
+
* {@link MAX_CONVERSATION_HISTORY_CHARS} characters with the oldest dropped first.
|
|
2126
|
+
*/
|
|
2127
|
+
private capConversationHistory(turns: Array<{ Role: string; Message: string | null }>): ChatMessage[] {
|
|
2128
|
+
const newest = turns.slice(-MAX_CONVERSATION_HISTORY_TURNS);
|
|
2129
|
+
const messages: ChatMessage[] = newest.map((t) => ({
|
|
2130
|
+
role: t.Role === 'AI' ? 'assistant' : 'user',
|
|
2131
|
+
content: (t.Message ?? '').trim(),
|
|
2132
|
+
}));
|
|
2133
|
+
let total = messages.reduce((sum, m) => sum + String(m.content).length + 1, 0);
|
|
2134
|
+
while (messages.length > 0 && total > MAX_CONVERSATION_HISTORY_CHARS) {
|
|
2135
|
+
const dropped = messages.shift() as ChatMessage;
|
|
2136
|
+
total -= String(dropped.content).length + 1;
|
|
2137
|
+
}
|
|
2138
|
+
return messages;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2028
2141
|
/**
|
|
2029
2142
|
* Walks the prior-session chain backwards from `lastSessionId`, returning the OWNED leg ids
|
|
2030
2143
|
* (newest first). The first leg must exist and be owned by the caller, else `[]` (logged);
|
|
@@ -1631,12 +1631,13 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1631
1631
|
if (!artifactVersion.FileID) return null;
|
|
1632
1632
|
|
|
1633
1633
|
try {
|
|
1634
|
-
//
|
|
1634
|
+
// The attachment service returns base64 rather than a Buffer — `Buffer` is Node-only,
|
|
1635
|
+
// and removing it from that signature is what let the service stop depending on the
|
|
1636
|
+
// storage SDKs and become usable from browser and React Native hosts.
|
|
1635
1637
|
const attachmentService = GetAttachmentService();
|
|
1636
|
-
const
|
|
1637
|
-
if (!
|
|
1638
|
+
const base64 = await attachmentService.DownloadFileContent(artifactVersion.FileID, contextUser, provider);
|
|
1639
|
+
if (!base64) return null;
|
|
1638
1640
|
|
|
1639
|
-
const base64 = buffer.toString('base64');
|
|
1640
1641
|
const mimeType = artifactVersion.MimeType || 'application/octet-stream';
|
|
1641
1642
|
return `data:${mimeType};base64,${base64}`;
|
|
1642
1643
|
} catch (err) {
|
|
@@ -114,7 +114,9 @@ export class SearchEntitiesResolver {
|
|
|
114
114
|
Groups: params.map(p => ({ EntityName: p.EntityName, Results: [] })),
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
|
-
|
|
117
|
+
// Search is a pure read, so the read-write pool is a valid fallback. Without it, a server
|
|
118
|
+
// with no read-only login configured gets `null` here and every search silently returns empty.
|
|
119
|
+
const md = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
118
120
|
const user = UserCache.Instance.Users.find(
|
|
119
121
|
(u) => u.Email.trim().toLowerCase() === userPayload.email.trim().toLowerCase()
|
|
120
122
|
);
|
|
@@ -32,6 +32,9 @@ export class SearchKnowledgeResultItem {
|
|
|
32
32
|
@Field()
|
|
33
33
|
EntityName: string;
|
|
34
34
|
|
|
35
|
+
@Field({ nullable: true })
|
|
36
|
+
EntityDisplayName?: string;
|
|
37
|
+
|
|
35
38
|
@Field()
|
|
36
39
|
RecordID: string;
|
|
37
40
|
|
|
@@ -461,6 +464,7 @@ export class SearchKnowledgeResolver extends ResolverBase {
|
|
|
461
464
|
Results: result.Results.map((r: SearchEngineResultItem) => ({
|
|
462
465
|
ID: r.ID,
|
|
463
466
|
EntityName: r.EntityName,
|
|
467
|
+
EntityDisplayName: r.EntityDisplayName,
|
|
464
468
|
RecordID: r.RecordID,
|
|
465
469
|
SourceType: r.SourceType,
|
|
466
470
|
ResultType: r.ResultType,
|
|
@@ -88,6 +88,7 @@ export class SearchKnowledgeSystemUserResolver extends ResolverBase {
|
|
|
88
88
|
Results: result.Results.map((r: SearchEngineResultItem) => ({
|
|
89
89
|
ID: r.ID,
|
|
90
90
|
EntityName: r.EntityName,
|
|
91
|
+
EntityDisplayName: r.EntityDisplayName,
|
|
91
92
|
RecordID: r.RecordID,
|
|
92
93
|
SourceType: r.SourceType,
|
|
93
94
|
ResultType: r.ResultType,
|
|
@@ -78,6 +78,19 @@ export class TransactionOutputType {
|
|
|
78
78
|
|
|
79
79
|
|
|
80
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Renders one refused transaction-group item for the server log, naming the position, entity and
|
|
83
|
+
* operation so an operator can tie the refusal back to the row the client sent.
|
|
84
|
+
*
|
|
85
|
+
* `CompleteMessage` rather than `Message` because a refusal can report itself through any of
|
|
86
|
+
* `Message`, `Error` or the `Errors` array — a validation failure populates only the last of the
|
|
87
|
+
* three, so reading `Message` alone logs an empty reason for exactly the case that matters most.
|
|
88
|
+
*/
|
|
89
|
+
function describeRefusedItem(index: number, item: TransactionItemInputType, entity: BaseEntity): string {
|
|
90
|
+
const reason = entity.LatestResult?.CompleteMessage?.trim();
|
|
91
|
+
return ` [${index}] ${item.OperationType} '${item.EntityName}': ${reason || 'refused without a reported reason'}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
81
94
|
export class TransactionResolver extends ResolverBase {
|
|
82
95
|
/**
|
|
83
96
|
* Maps a TransactionGroup item's operation type onto the SAME API-key scope path the singular
|
|
@@ -123,8 +136,14 @@ export class TransactionResolver extends ResolverBase {
|
|
|
123
136
|
const tg = await md.CreateTransactionGroup();
|
|
124
137
|
const entityObjects: BaseEntity[] = [];
|
|
125
138
|
const objectValues: any[] = [];
|
|
139
|
+
// #4309: Save()/Delete() report a LOGICAL refusal by returning false — they do not throw —
|
|
140
|
+
// and a refused row is never enrolled, because TransactionGroup.AddTransaction() is reached
|
|
141
|
+
// only from inside ProviderToUse.Save()/Delete(). Discarding that boolean let a group whose
|
|
142
|
+
// rows were all refused arrive at Submit() empty, take its legitimate "nothing to do"
|
|
143
|
+
// branch, and report success for writes that never happened.
|
|
144
|
+
const refusals: string[] = [];
|
|
126
145
|
|
|
127
|
-
for (const item of group.Items) {
|
|
146
|
+
for (const [index, item] of group.Items.entries()) {
|
|
128
147
|
// instantiate a new entity object for the item
|
|
129
148
|
const entity = await md.GetEntityObject(item.EntityName, context.userPayload.userRecord);
|
|
130
149
|
entityObjects.push(entity); // save for later for mapping variables if needed
|
|
@@ -147,17 +166,51 @@ export class TransactionResolver extends ResolverBase {
|
|
|
147
166
|
objectValues.push(itemValues);
|
|
148
167
|
entity.SetMany(itemValues, true);
|
|
149
168
|
entity.TransactionGroup = tg;
|
|
150
|
-
await entity.Save()
|
|
169
|
+
if (!await entity.Save()) {
|
|
170
|
+
refusals.push(describeRefusedItem(index, item, entity));
|
|
171
|
+
}
|
|
151
172
|
break;
|
|
152
173
|
case "Delete":
|
|
153
174
|
await entity.InnerLoad(pkey);
|
|
154
|
-
|
|
175
|
+
// AWAITED: GetDataObject() is async, and pushing the un-awaited Promise put
|
|
176
|
+
// one into `objectValues` — which is `any[]`, so neither tsc nor a
|
|
177
|
+
// floating-promise lint could see it. PrepareReturnValue's Delete branch
|
|
178
|
+
// then stringified that Promise, and JSON.stringify(Promise) is "{}", so
|
|
179
|
+
// EVERY Delete in a transaction group reported an empty payload, successful
|
|
180
|
+
// ones included. Worse than losing the payload: GraphQLDataProvider's Delete
|
|
181
|
+
// transaction callback validates the commit with
|
|
182
|
+
// `pk.Value !== results[pk.FieldName]`, so "{}" made every key mismatch and
|
|
183
|
+
// reported 'Transaction failed to commit' for a delete that DID commit.
|
|
184
|
+
// Awaiting costs nothing here — DataObjectParams defaults
|
|
185
|
+
// includeRelatedEntityData to false, which guards the method's only awaited
|
|
186
|
+
// work, so with no params this is GetAll() plus a filter loop.
|
|
187
|
+
objectValues.push(await entity.GetDataObject());
|
|
155
188
|
entity.TransactionGroup = tg;
|
|
156
|
-
await entity.Delete()
|
|
189
|
+
if (!await entity.Delete()) {
|
|
190
|
+
refusals.push(describeRefusedItem(index, item, entity));
|
|
191
|
+
}
|
|
157
192
|
break;
|
|
158
193
|
}
|
|
159
194
|
}
|
|
160
195
|
|
|
196
|
+
// A refused row never enrolled, so submitting now would commit only the SURVIVORS and
|
|
197
|
+
// still report unqualified success. Nothing has been written at this point — enrolment
|
|
198
|
+
// is deferral, and the provider does not touch the database until Submit() — so
|
|
199
|
+
// returning here leaves the database exactly as we found it.
|
|
200
|
+
//
|
|
201
|
+
// The predicate is the RETURN VALUE, not whether the group ended up empty: a row that
|
|
202
|
+
// is not dirty also fails to enrol and correctly returns true, and an empty group
|
|
203
|
+
// legitimately means "nothing to do" for a caller that enrolled nothing. That is also
|
|
204
|
+
// why this belongs here rather than in TransactionGroupBase.Submit() — this is the only
|
|
205
|
+
// layer that still knows which row was refused and why.
|
|
206
|
+
if (refusals.length > 0) {
|
|
207
|
+
LogError(
|
|
208
|
+
`TransactionResolver::ExecuteTransactionGroup --- ${refusals.length} of ${group.Items.length} ` +
|
|
209
|
+
`item(s) were refused, so the group was not submitted:\n${refusals.join('\n')}`
|
|
210
|
+
);
|
|
211
|
+
return await this.PrepareReturnValue(false, entityObjects, objectValues, group);
|
|
212
|
+
}
|
|
213
|
+
|
|
161
214
|
// now, we need to set the variables
|
|
162
215
|
if (group.Variables && group.Variables.length > 0) {
|
|
163
216
|
for (const networkVar of group.Variables) {
|