openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
459 lines (458 loc) • 22 kB
JavaScript
import { r as resolveGlobalMap } from "./global-singleton-0m-JziJW.mjs";
import { r as asNullableRecord } from "./record-coerce-TIsquoVR.mjs";
import { n as sha256Hex } from "./node-crypto-BInnzoVt.mjs";
import "./crypto-digest-y6oZN-_F.mjs";
import { t as _usingCtx } from "./usingCtx-DwOBYOJf.mjs";
import { A as parseAgentSessionKey } from "./session-key-BC4m_Ly5.mjs";
import { b as describeAskUserTool, n as ASK_USER_TOOL_DISPLAY_SUMMARY } from "./tool-description-presets-CcdpQmwX.mjs";
import { n as normalizeMessageChannel } from "./message-channel-core-JDeu-bNU.mjs";
import { t as isDeliverableMessageChannel } from "./message-channel-normalize-Cc89P3hp.mjs";
import { n as ToolInputError } from "./tool-input-error-mjW74R8m.mjs";
import { s as resolveAgentQuestionGatewayCall } from "./gateway-question-dispatch-BWeOPi_b.mjs";
import { f as isReplyDispatchDeliveryError } from "./reply-dispatcher-BmqXN2zF.mjs";
import { n as textResult } from "./tool-results-BCM3fdVS.mjs";
import "./common-CwfR3iO6.mjs";
import { a as registerPendingAgentQuestion, g as readQuestionRejection, h as createQuestionPromptLifetime, m as createGatewayQuestionCanceller, s as buildAgentHarnessQuestionPromptPayload } from "./gateway-question-BNvMdOLy.mjs";
import { d as withAgentQuestionAnswerAuthority, l as resolveAgentQuestionAnswerAuthority } from "./host-private-capabilities-D8EEgJHq.mjs";
import { a as runWithQuestionChannelDeliveries } from "./question-channel-runtime-j3i1l3mj.mjs";
import { n as QUESTION_RPC_GRACE_MS, r as normalizeAskUserParams, t as AskUserToolSchema } from "./ask-user-tool-normalization-CRAThhPu.mjs";
import { n as sendDurableMessageBatchCore, t as durableMessageBatchMayHaveReachedRecipient } from "./send-Do63XxQr.mjs";
import "./runtime-DsYC3m9u.mjs";
import { r as resolveControlUiSessionLinkBase } from "./control-ui-link-base-BwOaYXQb.mjs";
//#region src/agents/tools/question-prompt-send.ts
/** Builds a portable prompt sender for Gateway-scoped / loopback tool construction. */
function createChannelQuestionPromptDelivery(params) {
const cfg = params.cfg;
const channel = normalizeMessageChannel(params.channel);
const to = params.to?.toString().trim();
if (!channel || !to || !isDeliverableMessageChannel(channel)) return;
return {
messageChannel: channel,
send: async (payload, options) => {
settleChannelQuestionPromptSend(await sendDurableMessageBatchCore({
cfg,
channel,
to,
accountId: params.accountId,
threadId: params.threadId ?? void 0,
payloads: [payload],
bestEffort: false,
durability: "required",
deliveryRetryOwner: "caller",
signal: options?.signal
}));
}
};
}
function settleChannelQuestionPromptSend(send) {
if (durableMessageBatchMayHaveReachedRecipient(send)) return;
if (send.status === "failed") throw send.error;
throw new Error(send.status === "suppressed" ? `question prompt delivery was suppressed: ${send.reason}` : "question prompt delivery did not reach the conversation");
}
/**
* Publishes the prompt for an already-committed gateway question record.
*
* Rejects when the question cannot become answerable where it was asked, which is
* what tells the caller to cancel the pending record instead of waiting it out.
*/
async function sendQuestionToolPrompt(params) {
const { questionId, questions } = params;
const send = (payload) => runWithQuestionChannelDeliveries([questionId], () => params.signal ? params.send(payload, { signal: params.signal }) : params.send(payload));
if (params.toolName === "secrets") {
const binding = questions[0]?.secretStore;
if (!binding) return;
const controlUiBase = resolveControlUiSessionLinkBase(params.config);
const text = controlUiBase ? `🔑 Agent requests credential ${binding.name} (${binding.kind}). Reply is disabled for secrets — open to provide it: ${controlUiBase}/ask/${encodeURIComponent(questionId)}` : "Credential request unavailable here: no reachable Control UI link. Open a trusted Control UI or native app and retry, or ask the operator to enable Control UI and configure gateway.publicOrigin. Never send credentials in chat.";
await send({
text,
channelData: { askUser: { questionId } }
});
if (!controlUiBase) throw new Error(text);
return;
}
await send(buildAgentHarnessQuestionPromptPayload({
questionId,
questions: questions.map(({ questionId: id, ...question }) => Object.assign(question, { id })),
options: { intro: "Question for you:" }
}));
}
//#endregion
//#region src/agents/tools/ask-user-tool.ts
/** Built-in blocking user-question tool and its active-session answer bridge. */
const ASK_USER_PROMPT_RECHECK_MS = 50;
const askUserQuestions = resolveGlobalMap(Symbol.for("openclaw.askUserQuestions"));
/** Stable client-generated gateway question id shared with tool-start delivery. */
function buildAskUserQuestionId(toolCallId, sessionKey, runId, agentId) {
const identity = `${runId?.trim() || askUserSessionKey(sessionKey, agentId)}\0${toolCallId}`;
return `ask_${sha256Hex(identity).slice(0, 32)}`;
}
function askUserSessionKey(sessionKey, agentId) {
const normalizedSessionKey = sessionKey?.trim();
if (normalizedSessionKey && parseAgentSessionKey(normalizedSessionKey)) return normalizedSessionKey;
return `${agentId?.trim() || "unknown"}\0${normalizedSessionKey || "session:unknown"}`;
}
function findAskUserQuestionForSession(sessionKey) {
for (const question of askUserQuestions.values()) if (question.sessionKey === sessionKey) return question;
}
function transitionAskUserQuestion(state, phase) {
state.phase = phase;
for (const wake of state.waiters) wake();
state.waiters.clear();
}
function releaseAskUserQuestion(questionId) {
const state = askUserQuestions.get(questionId);
if (!state) return;
askUserQuestions.delete(questionId);
state.claim?.dispose();
for (const wake of state.waiters) wake();
state.waiters.clear();
}
async function waitForQuestionChange(state, signal) {
signal?.throwIfAborted();
await new Promise((resolve, reject) => {
const wake = () => {
signal?.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = () => {
state.waiters.delete(wake);
reject(signal?.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("ask_user aborted"));
};
state.waiters.add(wake);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/** Reserves one visible ask_user prompt slot before subscriber delivery. */
function reserveAskUserPromptDelivery(params) {
const sessionKey = askUserSessionKey(params.sessionKey, params.agentId);
if (findAskUserQuestionForSession(sessionKey)) return;
const questionId = buildAskUserQuestionId(params.toolCallId, params.sessionKey, params.runId, params.agentId);
if (askUserQuestions.has(questionId)) return;
askUserQuestions.set(questionId, {
questionId,
sessionKey,
questions: params.questions,
expiresAtMs: Date.now() + (params.timeoutSeconds ?? 900) * 1e3,
phase: { kind: "reserved" },
waiters: /* @__PURE__ */ new Set()
});
return { questionId };
}
/** Waits until policy-accepted tool execution has registered the gateway question. */
async function waitForAskUserPromptReady(questionId, gatewayCall = resolveAgentQuestionGatewayCall()) {
const state = askUserQuestions.get(questionId);
if (!state) return;
while (askUserQuestions.get(questionId) === state) {
if (state.phase.kind === "prompting" || state.phase.kind === "answerable" || state.phase.kind === "resolving" || state.phase.kind === "prompt-failed") return state.questions;
try {
const status = await readAskUserQuestionStatus(questionId, gatewayCall);
if (status === "pending") return state.questions;
if (typeof status === "string") return;
} catch {}
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
}
}
async function readAskUserQuestionStatus(questionId, gatewayCall) {
const result = await gatewayCall("question.list", { timeoutMs: QUESTION_RPC_GRACE_MS }, {});
const questions = asNullableRecord(result)?.questions;
const question = Array.isArray(questions) ? questions.find((candidate) => asNullableRecord(candidate)?.id === questionId) : void 0;
const status = asNullableRecord(question)?.status;
return typeof status === "string" ? status : void 0;
}
async function readAskUserQuestionStatusBeforeExpiry(questionId, expiresAtMs, gatewayCall) {
const remainingMs = expiresAtMs - Date.now();
if (remainingMs <= 0) return { kind: "expired" };
return await new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(expiryTimer);
resolve(result);
};
const expiryTimer = setTimeout(() => finish({ kind: "expired" }), remainingMs);
readAskUserQuestionStatus(questionId, gatewayCall).then((status) => finish({
kind: "status",
status
}), () => finish({ kind: "error" }));
});
}
/** Opens prompt delivery after question.request succeeds. */
function markAskUserPromptReady(questionId, questions) {
const state = askUserQuestions.get(questionId);
if (!state || state.phase.kind !== "reserved" && state.phase.kind !== "registering") return;
state.questions = questions;
transitionAskUserQuestion(state, { kind: "prompting" });
}
/** Records whether the originating-conversation prompt reached its delivery callback. */
function settleAskUserPromptDelivery(questionId, error) {
const state = askUserQuestions.get(questionId);
if (!state || state.phase.kind !== "prompting") return;
transitionAskUserQuestion(state, error === void 0 ? { kind: "answerable" } : {
kind: "prompt-failed",
error
});
}
/**
* Settles the prompt wait from the same run that published the prompt.
*
* Detached on purpose: the waiter below races prompt delivery against the answer,
* so this must not block the tool call that is registering that answer.
*/
function settleAfterOwnPromptDelivery(questionId, delivery) {
delivery.then(() => settleAskUserPromptDelivery(questionId), (error) => settleAskUserPromptDelivery(questionId, error));
}
/** Rechecks the Gateway immediately before exposing an answerable prompt. */
async function isAskUserPromptPending(questionId, gatewayCall = resolveAgentQuestionGatewayCall()) {
const state = askUserQuestions.get(questionId);
if (!state) return false;
while (askUserQuestions.get(questionId) === state) {
if (state.phase.kind === "resolving" || state.phase.kind === "prompt-failed") return false;
const read = await readAskUserQuestionStatusBeforeExpiry(questionId, state.expiresAtMs, gatewayCall);
if (read.kind === "expired") return false;
const currentState = askUserQuestions.get(questionId);
if (currentState !== state || currentState.phase.kind === "resolving" || currentState.phase.kind === "prompt-failed") return false;
if (read.kind === "status" && read.status === "pending") return true;
if (read.kind === "status" && typeof read.status === "string") return false;
const remainingMs = state.expiresAtMs - Date.now();
if (remainingMs <= 0) return false;
await new Promise((resolve) => {
setTimeout(resolve, Math.min(ASK_USER_PROMPT_RECHECK_MS, remainingMs));
});
}
return false;
}
/** Releases a tool-start reservation when policy rejects execution. */
function cancelAskUserPromptDelivery(toolCallId, sessionKey, runId, agentId) {
releaseAskUserQuestion(buildAskUserQuestionId(toolCallId, sessionKey, runId, agentId));
}
function answeredResult(questions, answers) {
const payload = {
status: "answered",
answers
};
const lines = questions.map((question) => {
const values = answers.answers[question.questionId] ?? [];
return `${question.header}: ${values.length > 0 ? values.join(", ") : "(no answer)"}`;
});
return textResult(`${lines.join("\n")}\n\n${JSON.stringify(payload, null, 2)}`, payload);
}
function noAnswerResult(status) {
const payload = { status: "no_answer" };
return textResult(`${status === "cancelled" ? "The question was cancelled; proceed with best judgment." : "No answer arrived; proceed with best judgment."}\n\n${JSON.stringify(payload, null, 2)}`, payload);
}
async function waitForPromptDelivery(state, signal) {
while (askUserQuestions.get(state.questionId) === state) {
if (state.phase.kind === "answerable" || state.phase.kind === "resolving") return {};
if (state.phase.kind === "prompt-failed") return { error: state.phase.error };
await waitForQuestionChange(state, signal);
}
return { error: /* @__PURE__ */ new Error("ask_user prompt is no longer active") };
}
function registerAskUserQuestionState(questionId, sessionKey, questions, timeoutSeconds, reserved) {
const state = reserved ?? {
questionId,
sessionKey,
questions,
expiresAtMs: 0,
phase: { kind: "registering" },
waiters: /* @__PURE__ */ new Set()
};
Object.assign(state, {
sessionKey,
questions
});
state.expiresAtMs = Date.now() + timeoutSeconds * 1e3;
transitionAskUserQuestion(state, { kind: "registering" });
askUserQuestions.set(questionId, state);
return state;
}
/** Shares question ownership and prompt delivery without installing a plaintext answer claim. */
function beginAskUserPromptDelivery(params) {
const questionId = buildAskUserQuestionId(params.toolCallId, params.sessionKey, params.runId, params.agentId);
const sessionKey = askUserSessionKey(params.sessionKey, params.agentId);
const reserved = askUserQuestions.get(questionId);
const existing = findAskUserQuestionForSession(sessionKey);
if (reserved && reserved.phase.kind !== "reserved" || existing && existing !== reserved) throw new ToolInputError("a question is already pending for this session; wait for it to resolve before requesting another");
const state = registerAskUserQuestionState(questionId, sessionKey, params.questions, params.timeoutSeconds, reserved);
return {
questionId,
hasSubscriber: reserved !== void 0 || params.deliverPrompt !== void 0,
markReady() {
if (reserved) {
markAskUserPromptReady(questionId, params.questions);
return;
}
if (params.deliverPrompt) {
markAskUserPromptReady(questionId, params.questions);
settleAfterOwnPromptDelivery(questionId, params.deliverPrompt(questionId));
return;
}
transitionAskUserQuestion(state, { kind: "answerable" });
},
waitForDelivery(signal) {
return waitForPromptDelivery(state, signal);
},
release() {
if (askUserQuestions.get(questionId) === state) releaseAskUserQuestion(questionId);
}
};
}
function resetPendingAskUserQuestionsForTest() {
for (const questionId of askUserQuestions.keys()) releaseAskUserQuestion(questionId);
}
if (process.env.VITEST || false) globalThis[Symbol.for("openclaw.askUserToolTestApi")] = { resetPendingAskUserQuestionsForTest };
/** Creates the main-session-only blocking ask_user tool. */
function createAskUserTool(params) {
const questionAuthority = resolveAgentQuestionAnswerAuthority();
const gatewayCall = resolveAgentQuestionGatewayCall(params.gatewayCall);
return {
label: "Ask User",
name: "ask_user",
displaySummary: ASK_USER_TOOL_DISPLAY_SUMMARY,
description: describeAskUserTool(),
parameters: AskUserToolSchema,
execute: async (toolCallId, args, signal) => {
try {
var _usingCtx$1 = _usingCtx();
const questionId = buildAskUserQuestionId(toolCallId, params.sessionKey, params.runId, params.agentId);
let normalized;
try {
signal?.throwIfAborted();
normalized = normalizeAskUserParams(args);
} catch (error) {
releaseAskUserQuestion(questionId);
throw error;
}
const sessionKey = askUserSessionKey(params.sessionKey, params.agentId);
const reserved = askUserQuestions.get(questionId);
const existing = findAskUserQuestionForSession(sessionKey);
if (reserved && reserved.phase.kind !== "reserved" || existing && existing !== reserved) throw new ToolInputError("ask_user already has a pending question for this session; wait for it to resolve before asking another");
const timeoutMs = normalized.timeoutSeconds * 1e3;
const publishOwnPrompt = reserved ? void 0 : params.questionPrompt?.send;
const prompt = _usingCtx$1.u(createQuestionPromptLifetime(signal));
const deliverPrompt = reserved?.phase.kind === "reserved" || publishOwnPrompt !== void 0;
const state = registerAskUserQuestionState(questionId, sessionKey, normalized.questions, normalized.timeoutSeconds, reserved);
let registered = false;
const cancelPendingQuestion = createGatewayQuestionCanceller({
gatewayCall,
questionId,
beforeCancel: prompt.close
});
const cancelOnAbort = () => {
prompt.close();
if (askUserQuestions.get(questionId) === state) releaseAskUserQuestion(questionId);
cancelPendingQuestion("run-abort");
};
const finishWait = async (result) => {
if (result.status === "pending") {
const answered = await cancelPendingQuestion("wait-timeout");
if (answered) return answeredResult(normalized.questions, answered.answers);
}
if (result.status === "answered") return answeredResult(normalized.questions, result.answers);
if (result.status === "pending" || result.status === "expired" || result.status === "cancelled") return noAnswerResult(result.status);
throw new Error("question.waitAnswer returned an invalid status");
};
try {
state.claim = withAgentQuestionAnswerAuthority(questionAuthority, () => registerPendingAgentQuestion({
questionId,
sessionKey,
questions: normalized.questions.map(({ questionId: id, ...question }) => ({
...question,
id
})),
gatewayCall: params.gatewayCall,
onCancel: () => {
prompt.close();
if (askUserQuestions.get(questionId) === state && state.phase.kind !== "reserved" && state.phase.kind !== "resolving" && state.phase.kind !== "prompt-failed") transitionAskUserQuestion(state, { kind: "resolving" });
}
}));
const registration = Promise.resolve().then(() => gatewayCall("question.request", {}, {
id: questionId,
questions: normalized.questions,
...params.agentId ? { agentId: params.agentId } : {},
...params.sessionKey ? { sessionKey: params.sessionKey } : {},
...params.runId ? { runId: params.runId } : {},
timeoutMs
}, signal ? { signal } : void 0));
state.claim.attachRegistration(registration);
const requestResult = await registration;
registered = true;
if (requestResult.id !== questionId) throw new Error("question.request returned an unexpected question id");
if (state.claim.isCancellationRequested()) {
const answered = await cancelPendingQuestion("superseded-input");
return answered ? answeredResult(normalized.questions, answered.answers) : noAnswerResult("cancelled");
}
signal?.addEventListener("abort", cancelOnAbort, { once: true });
if (signal?.aborted) {
cancelOnAbort();
signal.throwIfAborted();
}
const answerPromise = gatewayCall("question.waitAnswer", { timeoutMs: timeoutMs + QUESTION_RPC_GRACE_MS }, {
id: questionId,
timeoutMs,
includeResolutionId: true
}, signal ? { signal } : void 0).finally(prompt.close);
state.claim.setAnswer(answerPromise);
let consumed;
do
consumed = await Promise.race([state.claim.waitForResolution(), answerPromise.then(() => true)]);
while (!consumed && state.claim.isResolving());
if (deliverPrompt && !consumed) {
markAskUserPromptReady(questionId, normalized.questions);
if (publishOwnPrompt) settleAfterOwnPromptDelivery(questionId, sendQuestionToolPrompt({
toolName: "ask_user",
questionId,
questions: normalized.questions,
send: publishOwnPrompt,
signal: prompt.signal
}));
const promptDeliveryPromise = waitForPromptDelivery(state, signal);
const first = await Promise.race([promptDeliveryPromise.then((result) => ({
kind: "delivery",
result
})), answerPromise.then((result) => ({
kind: "answer",
result
}))]);
signal?.throwIfAborted();
if (first.kind === "answer") return await finishWait(first.result);
const deliveryResult = first.result;
if (deliveryResult.error !== void 0) {
const answered = await cancelPendingQuestion("prompt-delivery-failed");
if (answered) return answeredResult(normalized.questions, answered.answers);
if (isReplyDispatchDeliveryError(deliveryResult.error) && deliveryResult.error.outcome === "failed-deliver") {
const details = { status: "delivery_failed" };
return {
...textResult(`The prompt became visible, but its controls failed to deliver. The question was cancelled; no retry/fallback should be sent.\n\n${JSON.stringify(details, null, 2)}`, details),
terminate: true
};
}
throw new Error("ask_user prompt delivery failed", { cause: deliveryResult.error });
}
} else if (!consumed) transitionAskUserQuestion(state, { kind: "answerable" });
const result = await answerPromise;
signal?.throwIfAborted();
return await finishWait(result);
} catch (error) {
if (registered || readQuestionRejection(error)?.reason !== "QUESTION_ID_IN_USE") {
const answered = await cancelPendingQuestion(signal?.aborted ? "run-abort" : registered ? "tool-error" : "registration-failed");
if (!signal?.aborted && answered) return answeredResult(normalized.questions, answered.answers);
}
throw error;
} finally {
signal?.removeEventListener("abort", cancelOnAbort);
if (askUserQuestions.get(questionId) === state) releaseAskUserQuestion(questionId);
}
} catch (_) {
_usingCtx$1.e = _;
} finally {
_usingCtx$1.d();
}
}
};
}
//#endregion
export { reserveAskUserPromptDelivery as a, createChannelQuestionPromptDelivery as c, isAskUserPromptPending as i, sendQuestionToolPrompt as l, cancelAskUserPromptDelivery as n, settleAskUserPromptDelivery as o, createAskUserTool as r, waitForAskUserPromptReady as s, beginAskUserPromptDelivery as t };