mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-26 17:38:36 +00:00
fix(tui): refuse empty prompt.submit truncation without confirm
Stale truncate_before_user_ordinal=0 from a desynced Desktop client resolved to history[:0] and replace_messages() wiped the durable transcript. Require confirm_empty_truncate for that edge and have intentional first-turn restore/regenerate paths send it.
This commit is contained in:
parent
f7e2c0e2e2
commit
819e01c134
4 changed files with 67 additions and 5 deletions
|
|
@ -38,7 +38,8 @@ import {
|
|||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
runRewindSubmit,
|
||||
truncateSubmitParams
|
||||
} from '../session/hooks/use-prompt-actions/rewind'
|
||||
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
|
||||
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
|
||||
|
|
@ -274,7 +275,11 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
|
|||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: runtimeIdRef.current, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
{
|
||||
session_id: runtimeIdRef.current,
|
||||
text: plan.text,
|
||||
...truncateSubmitParams(plan.truncateOrdinal)
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ import {
|
|||
planEdit,
|
||||
planReload,
|
||||
planRestore,
|
||||
runRewindSubmit
|
||||
runRewindSubmit,
|
||||
truncateSubmitParams
|
||||
} from './rewind'
|
||||
import { useSlashCommand } from './slash'
|
||||
import { useSubmitPrompt } from './submit'
|
||||
|
|
@ -756,7 +757,11 @@ export function usePromptActions({
|
|||
try {
|
||||
await requestGateway(
|
||||
'prompt.submit',
|
||||
{ session_id: activeSessionId, text: plan.text, truncate_before_user_ordinal: plan.truncateOrdinal },
|
||||
{
|
||||
session_id: activeSessionId,
|
||||
text: plan.text,
|
||||
...truncateSubmitParams(plan.truncateOrdinal)
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,23 @@ import {
|
|||
|
||||
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
|
||||
/**
|
||||
* Build `prompt.submit` truncation params. Ordinal 0 truncates to an empty
|
||||
* transcript (restore/regenerate the first user turn) — the gateway refuses
|
||||
* that edge unless `confirm_empty_truncate` is set, so stale clients cannot
|
||||
* silently wipe a session via a leftover ordinal.
|
||||
*/
|
||||
export function truncateSubmitParams(truncateOrdinal: number | undefined): Record<string, unknown> {
|
||||
if (truncateOrdinal === undefined) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
truncate_before_user_ordinal: truncateOrdinal,
|
||||
...(truncateOrdinal === 0 ? { confirm_empty_truncate: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind a turn: `prompt.submit` with an optional `truncate_before_user_ordinal`
|
||||
* (drops that user turn + everything after). Idle rewinds submit directly
|
||||
|
|
@ -53,7 +70,7 @@ export async function runRewindSubmit(
|
|||
{
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
...truncateSubmitParams(truncateOrdinal)
|
||||
},
|
||||
PROMPT_SUBMIT_REQUEST_TIMEOUT_MS
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10225,6 +10225,41 @@ def _(rid, params: dict) -> dict:
|
|||
if ordinal < 0 or ordinal >= len(user_indices):
|
||||
return _err(rid, 4018, "target user message is no longer in session history")
|
||||
truncated = history[: user_indices[ordinal]]
|
||||
# Stale clients can attach truncate_before_user_ordinal=0 to an
|
||||
# ordinary submit. That resolves to history[:0] == [] and
|
||||
# replace_messages() DELETEs every durable row — silent total
|
||||
# transcript loss. Refuse the empty-truncation edge unless the
|
||||
# client explicitly opts in (legitimate restore/regenerate of the
|
||||
# first user turn).
|
||||
if (
|
||||
not truncated
|
||||
and history
|
||||
and not is_truthy_value(params.get("confirm_empty_truncate"))
|
||||
):
|
||||
logger.warning(
|
||||
"prompt.submit: REFUSED empty truncation of session %s "
|
||||
"(%d messages would be wiped; ordinal=%d).",
|
||||
sid,
|
||||
len(history),
|
||||
ordinal,
|
||||
)
|
||||
return _err(
|
||||
rid,
|
||||
4025,
|
||||
"truncation would erase the entire session transcript; "
|
||||
"resubmit with confirm_empty_truncate=true if this is intended",
|
||||
)
|
||||
# Info for routine rewind/edit cuts; warning only when the client
|
||||
# explicitly opts into wiping the whole transcript.
|
||||
log_fn = logger.warning if not truncated else logger.info
|
||||
log_fn(
|
||||
"prompt.submit: truncating session %s history %d -> %d messages "
|
||||
"(ordinal=%d)",
|
||||
sid,
|
||||
len(history),
|
||||
len(truncated),
|
||||
ordinal,
|
||||
)
|
||||
session["history"] = truncated
|
||||
session["history_version"] = int(session.get("history_version", 0)) + 1
|
||||
if (db := _get_db()) is not None:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue