De-hardcode orchestrator and interaction agent names (#748)
This commit was merged in pull request #748.
This commit is contained in:
209
packages/coord/src/interaction-coordination.ts
Normal file
209
packages/coord/src/interaction-coordination.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CoordinationScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly correlationId: string;
|
||||
/** Trusted gateway/configuration identity; never supplied by a channel client. */
|
||||
readonly requesterAgentId: string;
|
||||
}
|
||||
|
||||
export interface InteractionCoordinationIdentity {
|
||||
readonly interactionAgentId: string;
|
||||
readonly orchestrationAgentId: string;
|
||||
}
|
||||
|
||||
export interface HandoffRequest {
|
||||
readonly idempotencyKey: string;
|
||||
readonly summary: string;
|
||||
readonly context?: string;
|
||||
readonly missionId?: string;
|
||||
}
|
||||
|
||||
export interface Handoff {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly request: HandoffRequest;
|
||||
readonly scope: CoordinationScope;
|
||||
}
|
||||
|
||||
export interface HandoffReceipt {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'queued' | 'accepted';
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface InteractionCoordinationActivity {
|
||||
readonly occurredAt: string;
|
||||
readonly status: HandoffStatus;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
export interface CoordinationObservation {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: HandoffStatus;
|
||||
readonly correlationId: string;
|
||||
readonly activity: readonly InteractionCoordinationActivity[];
|
||||
}
|
||||
|
||||
export interface CoordinationResult {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'completed' | 'failed' | 'pending';
|
||||
readonly correlationId: string;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral boundary. The interaction plane can request work and read
|
||||
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||
* general orchestration commands.
|
||||
*/
|
||||
export interface InteractionCoordinationPort {
|
||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||
}
|
||||
|
||||
export type InteractionCoordinationAuthorityErrorCode =
|
||||
| 'invalid_identity'
|
||||
| 'requester_forbidden'
|
||||
| 'target_drift'
|
||||
| 'correlation_drift';
|
||||
|
||||
export class InteractionCoordinationAuthorityError extends Error {
|
||||
constructor(
|
||||
readonly code: InteractionCoordinationAuthorityErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InteractionCoordinationAuthorityError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the interaction-to-orchestration authority boundary before a
|
||||
* transport is reached. Identity names remain configuration data.
|
||||
*/
|
||||
export class InteractionCoordinationClient {
|
||||
private readonly identity: InteractionCoordinationIdentity;
|
||||
|
||||
constructor(
|
||||
identity: InteractionCoordinationIdentity,
|
||||
private readonly port: InteractionCoordinationPort,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {
|
||||
this.identity = normalizeIdentity(identity);
|
||||
}
|
||||
|
||||
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
|
||||
this.assertRequester(scope);
|
||||
const handoff: Handoff = {
|
||||
handoffId: this.handoffIdFactory(),
|
||||
targetAgentId: this.identity.orchestrationAgentId,
|
||||
request: snapshotRequest(request),
|
||||
scope: snapshotScope(scope),
|
||||
};
|
||||
const receipt = await this.port.handoff(handoff);
|
||||
if (
|
||||
receipt.handoffId !== handoff.handoffId ||
|
||||
receipt.targetAgentId !== handoff.targetAgentId
|
||||
) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Interaction coordination transport returned a mismatched handoff target',
|
||||
);
|
||||
}
|
||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Interaction coordination transport returned a mismatched correlation ID',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
|
||||
this.assertRequester(scope);
|
||||
return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope);
|
||||
}
|
||||
|
||||
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
|
||||
this.assertRequester(scope);
|
||||
return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope);
|
||||
}
|
||||
|
||||
private assertRequester(scope: CoordinationScope): void {
|
||||
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'requester_forbidden',
|
||||
'Requester is not the configured interaction agent',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertObservation(
|
||||
observation: CoordinationObservation,
|
||||
scope: CoordinationScope,
|
||||
): CoordinationObservation {
|
||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Interaction coordination transport returned an unexpected observation target',
|
||||
);
|
||||
}
|
||||
if (observation.correlationId !== scope.correlationId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Interaction coordination transport returned a mismatched observation correlation ID',
|
||||
);
|
||||
}
|
||||
return observation;
|
||||
}
|
||||
|
||||
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Interaction coordination transport returned an unexpected result target',
|
||||
);
|
||||
}
|
||||
if (result.correlationId !== scope.correlationId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Interaction coordination transport returned a mismatched result correlation ID',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(
|
||||
identity: InteractionCoordinationIdentity,
|
||||
): InteractionCoordinationIdentity {
|
||||
const interactionAgentId = identity.interactionAgentId.trim();
|
||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities are required',
|
||||
);
|
||||
}
|
||||
if (interactionAgentId === orchestrationAgentId) {
|
||||
throw new InteractionCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities must differ',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||
}
|
||||
|
||||
function snapshotRequest(request: HandoffRequest): HandoffRequest {
|
||||
return Object.freeze({ ...request });
|
||||
}
|
||||
|
||||
function snapshotScope(scope: CoordinationScope): CoordinationScope {
|
||||
return Object.freeze({ ...scope });
|
||||
}
|
||||
Reference in New Issue
Block a user