File size: 7,393 Bytes
f0634fb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 | /**
* ACP `session/request_permission` ↔ agent-core-v2 ask-user mappers.
*
* ACP has no dedicated `session/request_question` method, so the AskUserQuestion
* tool's question request is bridged through the same `requestPermission`
* surface approvals use, with option ids tagged in a `q{n}_*` namespace so the
* round-trip is unambiguous. Pure mappers — no IO — so the mappings stay
* unit-testable without a live connection.
*/
import type {
CreateElicitationRequest,
CreateElicitationResponse,
ElicitationPropertySchema,
EnumOption,
PermissionOption,
RequestPermissionResponse,
} from '@agentclientprotocol/sdk';
import type { QuestionAnswers, QuestionItem } from '@moonshot-ai/agent-core-v2';
/**
* `optionId` namespace for the AskUserQuestion bridge.
*
* The wire-level `PermissionOption.optionId` is opaque to the client (it
* round-trips back via `RequestPermissionResponse.outcome.optionId`), so the
* host is free to pick any stable string. The `questionIndex` is embedded in
* the prefix so future multi-question support does not need a wire-format
* change: `q0_opt_*` / `q1_opt_*` are already non-conflicting.
*/
function optOptionId(questionIndex: number, optionIndex: number): string {
return `q${questionIndex}_opt_${optionIndex}`;
}
function skipOptionId(questionIndex: number): string {
return `q${questionIndex}_skip`;
}
/**
* Map a tool-side {@link QuestionItem} into ACP {@link PermissionOption}[].
*
* Layout:
* - One `allow_once` option per `question.options[i]` (label preserved
* verbatim — it is the same string surfaced back to the engine as a
* `QuestionAnswers` value).
* - One trailing `reject_once` "Skip" option so the user can dismiss the
* prompt without forcing an answer (the engine's ask-user tool resolves
* dismissal as `question_dismissed`).
*
* `questionIndex` is currently always `0` (the bridge degrades multi-question
* to single-question); the namespace is wired in so future multi-question
* support is a pure handler change with no wire-format break.
*/
export function questionItemToPermissionOptions(
question: QuestionItem,
questionIndex: number,
): readonly PermissionOption[] {
const options: PermissionOption[] = question.options.map((opt, i) => ({
optionId: optOptionId(questionIndex, i),
name: opt.label,
kind: 'allow_once' as const,
}));
options.push({
optionId: skipOptionId(questionIndex),
name: 'Skip',
kind: 'reject_once' as const,
});
return options;
}
/**
* Reverse-map an ACP {@link RequestPermissionResponse} into a tool-side
* {@link QuestionAnswers} payload, returning `null` when the user dismissed
* (skip / cancel) or selected an unknown option.
*
* Defensive on out-of-bounds / unknown optionIds: returning `null` rather than
* throwing keeps the bridge robust against stale or custom options surfaced by
* the client.
*/
export function outcomeToQuestionAnswer(
question: QuestionItem,
response: RequestPermissionResponse,
): QuestionAnswers | null {
if (response.outcome.outcome === 'cancelled') return null;
const optionId = response.outcome.optionId;
if (optionId === skipOptionId(0)) return null;
const match = /^q0_opt_(\d+)$/.exec(optionId);
if (!match) return null;
const optionIndex = Number(match[1]);
if (!Number.isInteger(optionIndex) || optionIndex < 0) return null;
const selected = question.options[optionIndex];
if (!selected) return null;
return { [question.question]: selected.label };
}
// ---------------------------------------------------------------------------
// Elicitation bridge (`elicitation/create`, form mode)
// ---------------------------------------------------------------------------
/** Property key for question `i` in the elicitation form schema. */
function questionPropertyKey(questionIndex: number): string {
return `q${questionIndex}`;
}
/** Titled enum options shared by the single- (`oneOf`) and multi- (`anyOf`) select arms. */
function titledEnumOptions(question: QuestionItem): EnumOption[] {
return question.options.map((opt) => ({
const: opt.label,
title: opt.label,
description: opt.description,
}));
}
/**
* Map a tool-side question set into an `elicitation/create` form-mode request.
*
* Unlike the `request_permission` bridge (single question, single select),
* the form schema carries EVERY question natively: single-select questions
* become `type: 'string'` + `oneOf`, `multiSelect` questions become
* `type: 'array'` + `items.anyOf` (with `minItems: 1`, since every question
* is required). The form-level `message` joins the question texts; each
* field is titled by the question's `header` (falling back to the full
* question text) and described by its `body`.
*
* The synthetic "Other" free-text option (`otherLabel`) has no elicitation
* equivalent without an extra text field; it stays unsupported for now,
* matching the `request_permission` bridge.
*/
export function questionRequestToElicitationParams(
questions: readonly QuestionItem[],
sessionId: string,
toolCallId?: string,
): Extract<CreateElicitationRequest, { mode: 'form' }> {
const properties: Record<string, ElicitationPropertySchema> = {};
const required: string[] = [];
questions.forEach((q, i) => {
const key = questionPropertyKey(i);
required.push(key);
const title = q.header ?? q.question;
properties[key] =
q.multiSelect === true
? {
type: 'array',
title,
description: q.body,
minItems: 1,
items: { anyOf: titledEnumOptions(q) },
}
: {
type: 'string',
title,
description: q.body,
oneOf: titledEnumOptions(q),
};
});
return {
sessionId,
toolCallId,
mode: 'form',
message: questions.map((q) => q.question).join('\n'),
requestedSchema: { type: 'object', properties, required },
};
}
/**
* Reverse-map an `elicitation/create` response into a tool-side
* {@link QuestionAnswers} payload. `decline` / `cancel` (and an `accept`
* without content) resolve to `null` — the tool's canonical "user dismissed"
* branch. Multi-select values join with `', '` in DECLARED option order,
* matching the TUI's `QuestionDialog` encoding. Values outside the declared
* options are dropped defensively; an accept that answers nothing resolves
* to `null` as well.
*/
export function elicitationResponseToQuestionAnswers(
questions: readonly QuestionItem[],
response: CreateElicitationResponse,
): QuestionAnswers | null {
if (response.action !== 'accept') return null;
const content = (response as { content?: Record<string, unknown> | null }).content;
if (content === null || content === undefined) return null;
const answers: QuestionAnswers = {};
questions.forEach((q, i) => {
const value = content[questionPropertyKey(i)];
if (q.multiSelect === true) {
if (!Array.isArray(value)) return;
const picked = q.options
.map((opt) => opt.label)
.filter((label) => value.includes(label));
if (picked.length > 0) answers[q.question] = picked.join(', ');
return;
}
if (typeof value === 'string' && q.options.some((opt) => opt.label === value)) {
answers[q.question] = value;
}
});
return Object.keys(answers).length > 0 ? answers : null;
}
|