304 lines
9.9 KiB
TypeScript
304 lines
9.9 KiB
TypeScript
import { Inject, Injectable } from '@nestjs/common';
|
|
import {
|
|
InteractionCoordinationClient,
|
|
type CoordinationObservation,
|
|
type CoordinationResult,
|
|
type CoordinationScope,
|
|
type InteractionCoordinationIdentity,
|
|
type InteractionCoordinationPort,
|
|
type HandoffReceipt,
|
|
} from '@mosaicstack/coord';
|
|
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
|
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|
|
|
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
|
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
|
|
|
|
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
|
const MAX_TRACKED_HANDOFFS = 1_000;
|
|
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
|
const MAX_SUMMARY_LENGTH = 2_048;
|
|
const MAX_CONTEXT_LENGTH = 8_192;
|
|
const MAX_MISSION_ID_LENGTH = 128;
|
|
|
|
export interface InteractionCoordinationConfig {
|
|
interactionAgentId?: string;
|
|
orchestrationAgentId?: string;
|
|
}
|
|
|
|
interface HandoffOwner {
|
|
actorId: string;
|
|
tenantId: string;
|
|
requesterAgentId: string;
|
|
correlationId: string;
|
|
expiresAt: number;
|
|
}
|
|
|
|
interface NormalizedHandoffRequest {
|
|
idempotencyKey: string;
|
|
summary: string;
|
|
context?: string;
|
|
missionId?: string;
|
|
}
|
|
|
|
interface TrackedHandoff {
|
|
request: NormalizedHandoffRequest;
|
|
receipt: Promise<HandoffReceipt>;
|
|
expiresAt: number;
|
|
}
|
|
|
|
/**
|
|
* Gateway authority boundary for the interaction agent. It derives requester,
|
|
* actor, and tenant from trusted server configuration and authentication; no
|
|
* channel request can name a target or gain orchestrator-owned orchestration verbs.
|
|
*/
|
|
@Injectable()
|
|
export class InteractionCoordinationService {
|
|
private readonly owners = new Map<string, HandoffOwner>();
|
|
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
|
|
|
constructor(
|
|
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
|
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
|
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
|
) {}
|
|
|
|
async handoff(
|
|
request: CreateHandoffDto,
|
|
context: RuntimeProviderRequestContext,
|
|
): Promise<HandoffReceipt> {
|
|
this.pruneExpiredTracking();
|
|
const normalized = this.normalizeRequest(request);
|
|
const scope = this.scope(context);
|
|
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
|
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
|
if (existing !== undefined) {
|
|
if (!sameRequest(existing.request, normalized)) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'handoff_conflict',
|
|
'Handoff idempotency key is already bound to different immutable input',
|
|
);
|
|
}
|
|
return existing.receipt;
|
|
}
|
|
|
|
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
|
const tracked: TrackedHandoff = {
|
|
request: normalized,
|
|
receipt: pending,
|
|
expiresAt: this.expiresAt(),
|
|
};
|
|
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
|
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
|
try {
|
|
return await pending;
|
|
} catch (error: unknown) {
|
|
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
|
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async observe(
|
|
handoffId: string,
|
|
context: RuntimeProviderRequestContext,
|
|
): Promise<CoordinationObservation> {
|
|
this.pruneExpiredTracking();
|
|
const scope = this.scope(context);
|
|
const owner = this.ownerFor(handoffId, scope);
|
|
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
|
}
|
|
|
|
async result(
|
|
handoffId: string,
|
|
context: RuntimeProviderRequestContext,
|
|
): Promise<CoordinationResult> {
|
|
this.pruneExpiredTracking();
|
|
const scope = this.scope(context);
|
|
const owner = this.ownerFor(handoffId, scope);
|
|
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
|
}
|
|
|
|
private async deliverHandoff(
|
|
handoffId: string,
|
|
request: NormalizedHandoffRequest,
|
|
scope: CoordinationScope,
|
|
): Promise<HandoffReceipt> {
|
|
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
|
const owner: HandoffOwner = {
|
|
actorId: scope.actorId,
|
|
tenantId: scope.tenantId,
|
|
requesterAgentId: scope.requesterAgentId,
|
|
correlationId: scope.correlationId,
|
|
expiresAt: this.expiresAt(),
|
|
};
|
|
const existing = this.owners.get(receipt.handoffId);
|
|
if (existing !== undefined && !sameOwner(existing, owner)) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'handoff_conflict',
|
|
'Handoff ID is already bound to a different authenticated scope',
|
|
);
|
|
}
|
|
this.owners.set(receipt.handoffId, owner);
|
|
this.enforceTrackingLimit(this.owners);
|
|
return receipt;
|
|
}
|
|
|
|
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
|
|
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
|
}
|
|
|
|
private identity(): InteractionCoordinationIdentity {
|
|
const interactionAgentId = this.config.interactionAgentId?.trim();
|
|
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
|
if (!interactionAgentId) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'unconfigured_requester',
|
|
'Interaction agent identity is not configured',
|
|
);
|
|
}
|
|
if (!orchestrationAgentId) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'unconfigured_target',
|
|
'Orchestration agent identity is not configured',
|
|
);
|
|
}
|
|
return { interactionAgentId, orchestrationAgentId };
|
|
}
|
|
|
|
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
|
const identity = this.identity();
|
|
return Object.freeze({
|
|
actorId: context.actorScope.userId,
|
|
tenantId: context.actorScope.tenantId,
|
|
correlationId: context.correlationId,
|
|
requesterAgentId: identity.interactionAgentId,
|
|
});
|
|
}
|
|
|
|
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
|
|
if (typeof request !== 'object' || request === null) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'invalid_request',
|
|
'Handoff request is invalid',
|
|
);
|
|
}
|
|
const idempotencyKey = this.requiredString(
|
|
request.idempotencyKey,
|
|
'idempotency key',
|
|
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
);
|
|
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
|
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
|
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
|
return Object.freeze({
|
|
idempotencyKey,
|
|
summary,
|
|
...(context === undefined ? {} : { context }),
|
|
...(missionId === undefined ? {} : { missionId }),
|
|
});
|
|
}
|
|
|
|
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
|
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
|
}
|
|
|
|
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
|
if (typeof value !== 'string') {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'invalid_request',
|
|
`Handoff ${field} must be a string`,
|
|
);
|
|
}
|
|
const normalized = value.trim();
|
|
if (normalized.length === 0 || normalized.length > maximumLength) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'invalid_request',
|
|
`Handoff ${field} is invalid`,
|
|
);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
|
if (value === undefined) return undefined;
|
|
return this.requiredString(value, field, maximumLength);
|
|
}
|
|
|
|
private expiresAt(): number {
|
|
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
|
}
|
|
|
|
private pruneExpiredTracking(): void {
|
|
const now = Date.now();
|
|
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
|
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
|
}
|
|
for (const [key, owner] of this.owners) {
|
|
if (owner.expiresAt <= now) this.owners.delete(key);
|
|
}
|
|
}
|
|
|
|
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
|
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
|
const oldest = entries.keys().next().value;
|
|
if (typeof oldest !== 'string') return;
|
|
entries.delete(oldest);
|
|
}
|
|
}
|
|
|
|
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
|
const owner = this.owners.get(handoffId);
|
|
if (owner === undefined) {
|
|
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
|
}
|
|
if (
|
|
owner.tenantId !== scope.tenantId ||
|
|
owner.actorId !== scope.actorId ||
|
|
owner.requesterAgentId !== scope.requesterAgentId
|
|
) {
|
|
throw new InteractionCoordinationGatewayError(
|
|
'cross_tenant_forbidden',
|
|
'Handoff is outside the authenticated scope',
|
|
);
|
|
}
|
|
return owner;
|
|
}
|
|
}
|
|
|
|
export type InteractionCoordinationGatewayErrorCode =
|
|
| 'cross_tenant_forbidden'
|
|
| 'handoff_conflict'
|
|
| 'invalid_request'
|
|
| 'not_found'
|
|
| 'unconfigured_requester'
|
|
| 'unconfigured_target';
|
|
|
|
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
|
return (
|
|
left.actorId === right.actorId &&
|
|
left.tenantId === right.tenantId &&
|
|
left.requesterAgentId === right.requesterAgentId
|
|
);
|
|
}
|
|
|
|
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
|
return (
|
|
left.idempotencyKey === right.idempotencyKey &&
|
|
left.summary === right.summary &&
|
|
left.context === right.context &&
|
|
left.missionId === right.missionId
|
|
);
|
|
}
|
|
|
|
export class InteractionCoordinationGatewayError extends Error {
|
|
constructor(
|
|
readonly code: InteractionCoordinationGatewayErrorCode,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = InteractionCoordinationGatewayError.name;
|
|
}
|
|
}
|