aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
370 lines • 15.3 kB
JavaScript
// HITL orchestrator driver — glues the SSE stream consumer, envelope
// extractor, delivery adapter, schema validator, and reply poster
// together.
//
// Usage:
//
// import { A2AClient } from './client.js';
// import { CliHitlDeliveryAdapter } from './hitl-cli.js';
// import { driveHitlPromptsForTask } from './hitl-driver.js';
//
// const client = new A2AClient({ ... });
// await driveHitlPromptsForTask({
// client,
// taskId,
// adapter: new CliHitlDeliveryAdapter(),
// auditLog,
// });
//
// The driver:
// 1. Subscribes to the task's SSE stream.
// 2. For each `status-update` with `state === 'input-required'`, extracts
// the hitl-prompt envelope (returns silently if absent — not every
// input-required state carries a HITL prompt).
// 3. Validates the envelope, sets up a deadline-based AbortController if
// `envelope.deadline` is present.
// 4. Routes to the configured delivery adapter to collect a response.
// 5. Validates the response against `envelope.response_schema` using Ajv.
// 6. POSTs the reply Message via `client.sendMessage`. Surfaces 422
// `hitl_response_invalid` as a clear operator error and re-prompts.
// 7. Writes one audit-log entry per decision.
//
// Concurrent prompts are correlated by `prompt_id` and tracked in an
// in-memory Map so a single task may have multiple outstanding HITL
// prompts simultaneously (per spec).
//
// @issue #1255
import { createRequire } from 'node:module';
import { existsSync } from 'node:fs';
import { dirname, join, resolve as resolvePath } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildHitlResponseMessage, extractHitlEnvelope, validateResponseStructurally, } from './hitl.js';
/** Default audit log — writes to stderr as JSONL. Replace with a file/HTTP sink in production. */
export class StderrHitlAuditLog {
append(entry) {
process.stderr.write(JSON.stringify({ hitl_audit: entry }) + '\n');
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _ajvCompile = null;
let _ajvBootstrapped = false;
function bootstrapAjv() {
if (_ajvBootstrapped)
return;
_ajvBootstrapped = true;
try {
const require = createRequire(fileURLToPath(import.meta.url));
const projectRoot = resolvePath(dirname(fileURLToPath(import.meta.url)), '..', '..');
const candidates = [
join(projectRoot, 'node_modules', 'ajv', 'dist', '2020.js'),
join(projectRoot, 'node_modules', 'ajv', 'dist', 'ajv.js'),
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let Ajv = null;
for (const p of candidates) {
if (existsSync(p)) {
try {
Ajv = require(p);
break;
}
catch {
/* try next */
}
}
}
if (!Ajv)
return;
const AjvClass = Ajv.default ?? Ajv;
// strict: false because operator-supplied schemas vary widely; we
// accept whatever they declare. validateSchema: false avoids meta-
// schema URI fetches.
const ajv = new AjvClass({
strict: false,
allErrors: true,
validateSchema: false,
});
const formatsPath = join(projectRoot, 'node_modules', 'ajv-formats', 'dist', 'index.js');
if (existsSync(formatsPath)) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fm = require(formatsPath);
const addFormats = fm.default ?? fm;
if (typeof addFormats === 'function')
addFormats(ajv);
}
catch {
/* formats are optional */
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
_ajvCompile = (schema) => ajv.compile(schema);
}
catch {
/* graceful degrade to structural validator */
}
}
/**
* Validate `response` against the JSON Schema in `envelope.response_schema`.
* Uses Ajv when available (transitive dep); falls back to the structural
* validator in `hitl.ts` otherwise.
*/
export function validateResponseAgainstSchema(envelope, response) {
bootstrapAjv();
if (_ajvCompile) {
try {
const validate = _ajvCompile(envelope.response_schema);
if (validate(response))
return { ok: true };
const errors = (validate.errors ?? []).map(formatAjvError);
return { ok: false, errors };
}
catch (e) {
// Schema itself was malformed — fall through to structural.
// Don't swallow silently: include the error in fallback notes.
const structural = validateResponseStructurally(envelope.response_schema, response);
if (structural.ok)
return structural;
return {
ok: false,
errors: [
`ajv refused schema (${e.message}); structural fallback:`,
...structural.errors,
],
};
}
}
return validateResponseStructurally(envelope.response_schema, response);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function formatAjvError(err) {
const path = err.instancePath || '$';
return `${path}: ${err.message ?? 'invalid'}`;
}
/**
* Subscribe to a task's SSE stream and drive HITL prompts to completion
* via the supplied adapter. Resolves when the stream closes (task reaches
* a terminal state) or when `maxPrompts` is reached.
*/
export async function driveHitlPromptsForTask(opts) {
const auditLog = opts.auditLog ?? new StderrHitlAuditLog();
const maxRetries = Math.max(1, opts.maxRevalidationRetries ?? 3);
const messageIdFactory = opts.messageIdFactory ?? defaultMessageIdFactory;
let prompts = 0;
const subscription = opts.client.subscribeToTask(opts.taskId, {
signal: opts.signal,
});
try {
for await (const event of subscription) {
const envelope = extractEnvelopeFromEvent(event);
if (!envelope)
continue;
const ctx = inferEventContext(event);
await driveOnePrompt({
envelope,
client: opts.client,
adapter: opts.adapter,
auditLog,
taskId: opts.taskId,
contextId: ctx.contextId,
deadline: envelope.deadline,
maxRetries,
messageIdFactory,
});
prompts++;
if (opts.maxPrompts !== undefined && prompts >= opts.maxPrompts)
break;
}
}
finally {
subscription.close();
}
}
/**
* Drive a single prompt-response cycle. Exposed for callers that want
* to bypass the SSE stream (e.g. when an envelope arrives via the push-
* notification webhook receiver in #1256).
*/
export async function driveOnePrompt(opts) {
const auditLog = opts.auditLog;
const maxRetries = Math.max(1, opts.maxRetries ?? 3);
const messageIdFactory = opts.messageIdFactory ?? defaultMessageIdFactory;
const startedAt = Date.now();
// Set up the deadline AbortController if the envelope declares one.
const controller = new AbortController();
const deadlineMs = parseDeadline(opts.envelope.deadline);
let deadlineTimer;
if (deadlineMs !== null) {
const remaining = deadlineMs - Date.now();
if (remaining <= 0) {
controller.abort();
}
else {
deadlineTimer = setTimeout(() => controller.abort(), remaining);
}
}
let lastError;
try {
for (let attempt = 0; attempt < maxRetries; attempt++) {
let response;
try {
response = await opts.adapter.collect(opts.envelope, {
...(opts.taskId !== undefined ? { taskId: opts.taskId } : {}),
...(opts.contextId !== undefined ? { contextId: opts.contextId } : {}),
signal: controller.signal,
});
}
catch (err) {
if (controller.signal.aborted) {
await auditLog.append({
decided_at: new Date().toISOString(),
operator: adapterOperator(opts.adapter),
channel: opts.adapter.name,
prompt_id: opts.envelope.prompt_id,
...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
outcome: 'expired',
error: deadlineMs !== null && Date.now() >= deadlineMs
? 'deadline expired before response'
: err.message,
duration_ms: Date.now() - startedAt,
});
return;
}
await auditLog.append({
decided_at: new Date().toISOString(),
operator: adapterOperator(opts.adapter),
channel: opts.adapter.name,
prompt_id: opts.envelope.prompt_id,
...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
outcome: 'aborted',
error: err.message,
duration_ms: Date.now() - startedAt,
});
return;
}
const validation = validateResponseAgainstSchema(opts.envelope, response);
if (!validation.ok) {
lastError = validation.errors.join('; ');
// Surface the schema gap clearly and let the operator retry.
process.stderr.write(`HITL response invalid: ${lastError}\n` +
(attempt + 1 < maxRetries
? `(retry ${attempt + 1}/${maxRetries - 1})\n`
: ''));
continue;
}
// Validation passed — POST the reply.
const reply = buildHitlResponseMessage({
promptId: opts.envelope.prompt_id,
response,
messageId: messageIdFactory(),
...(opts.taskId !== undefined ? { taskId: opts.taskId } : {}),
...(opts.contextId !== undefined ? { contextId: opts.contextId } : {}),
});
try {
await opts.client.sendMessage(reply);
}
catch (err) {
// Surface 422 hitl_response_invalid distinctly from network errors.
const msg = err.message;
if (/422|hitl_response_invalid/.test(msg)) {
lastError = msg;
process.stderr.write(`Executor rejected response (HTTP 422): ${msg}\n` +
(attempt + 1 < maxRetries
? `(retry ${attempt + 1}/${maxRetries - 1})\n`
: ''));
continue;
}
await auditLog.append({
decided_at: new Date().toISOString(),
operator: adapterOperator(opts.adapter),
channel: opts.adapter.name,
prompt_id: opts.envelope.prompt_id,
...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
outcome: 'send_failed',
error: msg,
duration_ms: Date.now() - startedAt,
});
throw err;
}
await auditLog.append({
decided_at: new Date().toISOString(),
operator: adapterOperator(opts.adapter),
channel: opts.adapter.name,
prompt_id: opts.envelope.prompt_id,
...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
outcome: 'responded',
response,
duration_ms: Date.now() - startedAt,
});
return;
}
// Exhausted retries — record the final invalid outcome.
await auditLog.append({
decided_at: new Date().toISOString(),
operator: adapterOperator(opts.adapter),
channel: opts.adapter.name,
prompt_id: opts.envelope.prompt_id,
...(opts.taskId !== undefined ? { task_id: opts.taskId } : {}),
...(opts.contextId !== undefined ? { context_id: opts.contextId } : {}),
outcome: 'invalid',
error: lastError ?? 'response did not validate after max retries',
duration_ms: Date.now() - startedAt,
});
}
finally {
if (deadlineTimer)
clearTimeout(deadlineTimer);
}
}
// ── helpers ────────────────────────────────────────────────────────────
function extractEnvelopeFromEvent(event) {
if (event.kind === 'task-state') {
const result = extractHitlEnvelope(event.task);
if (result?.ok)
return result.envelope;
return null;
}
if (event.kind === 'status-update') {
const result = extractHitlEnvelope(event.status);
if (result?.ok)
return result.envelope;
return null;
}
return null;
}
function inferEventContext(event) {
if (event.kind === 'task-state') {
return event.task.contextId !== undefined
? { contextId: event.task.contextId }
: {};
}
// status-update / artifact-update don't carry a contextId; the caller
// can pass one through `driveOnePrompt` directly if it has one cached.
return {};
}
function parseDeadline(deadline) {
if (!deadline)
return null;
const ms = Date.parse(deadline);
return Number.isFinite(ms) ? ms : null;
}
function adapterOperator(adapter) {
// CLI adapter exposes `operatorId`; other adapters may too. Fall back
// to the adapter name so the audit entry is never empty.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const id = adapter.operatorId;
return typeof id === 'string' && id.length > 0 ? id : adapter.name;
}
function defaultMessageIdFactory() {
// Prefer crypto.randomUUID; fall back to timestamp+random for older node.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const crypto = globalThis.crypto;
if (crypto && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `hitl-reply-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
}
//# sourceMappingURL=hitl-driver.js.map