Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,212 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { $gateway } from './gateway'
|
||||
import { $activeSessionId } from './session'
|
||||
|
||||
export interface ClarifyQuestion {
|
||||
/** Server-generated wire id (q0..qN) — clarify.respond keys answers by it. */
|
||||
qid: string
|
||||
question: string
|
||||
choices: string[] | null
|
||||
multiSelect: boolean
|
||||
}
|
||||
|
||||
export interface ClarifyRequest {
|
||||
requestId: string
|
||||
question: string
|
||||
choices: string[] | null
|
||||
multiSelect: boolean
|
||||
/** Local receipt time (Unix seconds), used to reject stale resume cleanup. */
|
||||
receivedAt?: number
|
||||
sessionId: string | null
|
||||
/** Batch (multi-question) clarify: present instead of question/choices. */
|
||||
questions?: ClarifyQuestion[]
|
||||
/** Answers already locked server-side (reconnect replay): qid → answer. */
|
||||
lockedAnswers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend labels the agent's recommended option by appending this to the
|
||||
* first choice (`tools/clarify_tool.py::mark_recommended`). The renderer never
|
||||
* writes it — it only styles it, and discounts it when measuring a choice so a
|
||||
* long option isn't dropped for length the label added.
|
||||
*/
|
||||
export const RECOMMENDED_LABEL = '(Recommended)'
|
||||
|
||||
export const bareChoice = (choice: string): string =>
|
||||
choice.endsWith(RECOMMENDED_LABEL) ? choice.slice(0, -RECOMMENDED_LABEL.length).trim() : choice
|
||||
|
||||
/**
|
||||
* Validate and normalize a choices array.
|
||||
*
|
||||
* Keeps non-blank, newline-free strings of length ≤ 200; drops everything else
|
||||
* and returns an empty array when nothing usable survives — the caller then
|
||||
* falls back to a free-text answer instead of dead buttons.
|
||||
*/
|
||||
export function normalizeChoices(choices: unknown): string[] {
|
||||
if (!Array.isArray(choices)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return choices.filter(
|
||||
(c): c is string => typeof c === 'string' && c.trim().length > 0 && bareChoice(c).length <= 200 && !c.includes('\n')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured warning for a clarify payload that arrived with choices but had
|
||||
* them all normalized away — keeps the remaining #69122 "no selectable choices"
|
||||
* triggers diagnosable in the field without dead constant fields.
|
||||
*/
|
||||
export function warnDroppedChoices(source: 'gateway' | 'tool_args', question: string, rawChoices: unknown): void {
|
||||
console.warn('[clarify] choices dropped after normalization', {
|
||||
choices_count: Array.isArray(rawChoices) ? rawChoices.length : 0,
|
||||
question_length: question.length,
|
||||
source
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a batch clarify payload's `questions` array.
|
||||
*
|
||||
* Keeps entries with a non-blank string `qid` and `question`; per-question
|
||||
* choices go through `normalizeChoices` (all-blank → open-ended) and
|
||||
* multi_select is only honored alongside surviving choices. Returns an empty
|
||||
* array when nothing usable remains — the caller treats that as "not a
|
||||
* batch" instead of rendering an unanswerable form.
|
||||
*/
|
||||
export function normalizeQuestions(questions: unknown): ClarifyQuestion[] {
|
||||
if (!Array.isArray(questions)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized: ClarifyQuestion[] = []
|
||||
|
||||
for (const entry of questions) {
|
||||
if (typeof entry !== 'object' || entry === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
const row = entry as Record<string, unknown>
|
||||
const qid = typeof row.qid === 'string' ? row.qid.trim() : ''
|
||||
const question = typeof row.question === 'string' ? row.question.trim() : ''
|
||||
|
||||
if (!qid || !question) {
|
||||
continue
|
||||
}
|
||||
|
||||
const choices = normalizeChoices(row.choices)
|
||||
|
||||
normalized.push({
|
||||
choices: choices.length > 0 ? choices : null,
|
||||
multiSelect: row.multi_select === true && choices.length > 0,
|
||||
qid,
|
||||
question
|
||||
})
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Pending clarify requests keyed by the runtime session id that raised them.
|
||||
// Storing per-session (instead of one shared slot) lets a *background* session
|
||||
// park its clarify request while the user is looking at a different chat, then
|
||||
// resolve it once they switch over — without a second concurrent clarify
|
||||
// clobbering the first. A request with no session id lands under the empty key.
|
||||
const keyFor = (sessionId: string | null | undefined): string => sessionId ?? ''
|
||||
|
||||
export const $clarifyRequests = atom<Record<string, ClarifyRequest>>({})
|
||||
|
||||
// The clarify request for the currently-viewed session. The inline ClarifyTool
|
||||
// only ever mounts inside the active session's transcript, so it reads this
|
||||
// focus-scoped view rather than reaching into the whole map.
|
||||
export const $clarifyRequest = computed(
|
||||
[$clarifyRequests, $activeSessionId],
|
||||
(requests, activeId) => requests[keyFor(activeId)] ?? null
|
||||
)
|
||||
|
||||
/** The clarify request for one specific session — the tile counterpart of the
|
||||
* active-session `$clarifyRequest` view (same map, fixed key). */
|
||||
export const sessionClarifyRequest = (sessionId: string | null) =>
|
||||
computed($clarifyRequests, requests => requests[keyFor(sessionId)] ?? null)
|
||||
|
||||
export function setClarifyRequest(request: ClarifyRequest): void {
|
||||
$clarifyRequests.set({ ...$clarifyRequests.get(), [keyFor(request.sessionId)]: request })
|
||||
}
|
||||
|
||||
export function clearClarifyRequest(requestId?: string, sessionId?: string | null): void {
|
||||
const requests = $clarifyRequests.get()
|
||||
|
||||
// Targeted clear when the caller knows the session (the common path from the
|
||||
// inline ClarifyTool answering its own request).
|
||||
if (sessionId !== undefined) {
|
||||
const key = keyFor(sessionId)
|
||||
const current = requests[key]
|
||||
|
||||
if (!current || (requestId && current.requestId !== requestId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const next = { ...requests }
|
||||
delete next[key]
|
||||
$clarifyRequests.set(next)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback with no session hint: drop every entry matching the request id
|
||||
// (or clear all when none is given).
|
||||
const next: Record<string, ClarifyRequest> = {}
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(requests)) {
|
||||
if (requestId && value.requestId !== requestId) {
|
||||
next[key] = value
|
||||
} else {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
$clarifyRequests.set(next)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether `sessionId` has a clarify parked on it right now (imperative read —
|
||||
* the composer checks this on Enter, not on every render). */
|
||||
export const hasClarifyRequest = (sessionId: string | null | undefined): boolean =>
|
||||
Boolean($clarifyRequests.get()[keyFor(sessionId)])
|
||||
|
||||
/**
|
||||
* Answer `sessionId`'s pending clarify with an empty answer (a skip) and drop it
|
||||
* locally, resolving to whether there was one to skip.
|
||||
*
|
||||
* The composer uses this when the user types a real message instead of picking
|
||||
* an option: a clarify blocks the agent inside its tool batch, so leaving it
|
||||
* unanswered would park the follow-up until the server-side clarify timeout
|
||||
* (default 5 min) — the message looks sent and nothing happens. Skipping lets
|
||||
* the tool return and the turn carry on with the user's actual words.
|
||||
*
|
||||
* An empty answer is the same thing the card's own Skip button sends, and
|
||||
* `clarify.respond` is `allow_expired`, so racing the timeout is harmless.
|
||||
*/
|
||||
export async function skipClarifyRequest(sessionId: string | null | undefined): Promise<boolean> {
|
||||
const request = $clarifyRequests.get()[keyFor(sessionId)]
|
||||
|
||||
if (!request) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear first: the answer is already decided, and an in-flight RPC must not
|
||||
// leave a live card the user can answer a second time.
|
||||
clearClarifyRequest(request.requestId, request.sessionId)
|
||||
|
||||
try {
|
||||
await $gateway.get()?.request('clarify.respond', { request_id: request.requestId, answer: '' })
|
||||
} catch {
|
||||
// The tool times out on its own; a failed skip must never swallow the
|
||||
// message the user is actually sending.
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user