mirror of
https://github.com/Darkatse/TauriTavern.git
synced 2026-10-19 15:45:12 +00:00
Merge pull request #63 from yhny1001/fix/issue-55-frontend-ui
fix(agent): wire #55 frontend UI to userRetryable + run_rollback_targets
This commit is contained in:
commit
2809e7fcf7
12 changed files with 593 additions and 13 deletions
|
|
@ -192,6 +192,8 @@ run_failed
|
|||
}
|
||||
```
|
||||
|
||||
`messageId` 总是 chat 数组下标。对于 swipe / regenerate 这类把新内容追加成 swipe 的 generation type,回滚应只移除本次 run 新增的那条 swipe(保留用户先前的 swipes),而不是删整条 message。宿主在写入消息时已把回滚策略记录在 `extra.tauritavern.agent.rollback`(`{ strategy: "deleteSwipe", swipeId }` 或 `{ strategy: "deleteMessage" }`),消费 `run_rollback_targets` 时按 message 上的 `rollback.strategy` 选择删整条还是只删 swipe。
|
||||
|
||||
### 4.2 Workspace
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -142,10 +142,13 @@
|
|||
"agent_system.timelineDetailFieldRange": "范围",
|
||||
"agent_system.timelineDetailFieldReplaceAll": "全部替换",
|
||||
"agent_system.timelineDetailFieldReplacements": "替换次数",
|
||||
"agent_system.timelineActionRetry": "重试",
|
||||
"agent_system.timelineActionRetryHint": "回滚漂移产物后,使用相同设置重新生成 Agent 回复。",
|
||||
"agent_system.timelineDetailFieldRetryable": "可重试",
|
||||
"agent_system.timelineDetailFieldRound": "轮次",
|
||||
"agent_system.timelineDetailFieldStatus": "状态",
|
||||
"agent_system.timelineDetailFieldTarget": "目标",
|
||||
"agent_system.timelineDetailFieldUserRetryable": "用户可重试",
|
||||
"agent_system.timelineDetailRangeChars": "字符 {start}-{end}",
|
||||
"agent_system.timelineDetailRangeFull": "全文",
|
||||
"agent_system.timelineDetailRangeLines": "行 {start}-{end}",
|
||||
|
|
|
|||
|
|
@ -142,10 +142,13 @@
|
|||
"agent_system.timelineDetailFieldRange": "範圍",
|
||||
"agent_system.timelineDetailFieldReplaceAll": "全部替換",
|
||||
"agent_system.timelineDetailFieldReplacements": "替換次數",
|
||||
"agent_system.timelineActionRetry": "重試",
|
||||
"agent_system.timelineActionRetryHint": "回滾漂移產物後,使用相同設定重新生成 Agent 回覆。",
|
||||
"agent_system.timelineDetailFieldRetryable": "可重試",
|
||||
"agent_system.timelineDetailFieldRound": "輪次",
|
||||
"agent_system.timelineDetailFieldStatus": "狀態",
|
||||
"agent_system.timelineDetailFieldTarget": "目標",
|
||||
"agent_system.timelineDetailFieldUserRetryable": "使用者可重試",
|
||||
"agent_system.timelineDetailRangeChars": "字元 {start}-{end}",
|
||||
"agent_system.timelineDetailRangeFull": "全文",
|
||||
"agent_system.timelineDetailRangeLines": "行 {start}-{end}",
|
||||
|
|
|
|||
|
|
@ -184,8 +184,11 @@ const DEFAULT_MESSAGES = Object.freeze({
|
|||
timelineDetailFieldRange: 'Range',
|
||||
timelineDetailFieldReplaceAll: 'Replace all',
|
||||
timelineDetailFieldReplacements: 'Replacements',
|
||||
timelineActionRetry: 'Retry',
|
||||
timelineActionRetryHint: 'Retry the same Agent generation after rolling back drift artifacts.',
|
||||
timelineDetailFieldRetryable: 'Retryable',
|
||||
timelineDetailFieldRound: 'Round',
|
||||
timelineDetailFieldUserRetryable: 'User-retryable',
|
||||
timelineDetailFieldStatus: 'Status',
|
||||
timelineDetailFieldTarget: 'Target',
|
||||
timelineDetailRangeChars: 'chars {start}-{end}',
|
||||
|
|
|
|||
|
|
@ -106,11 +106,13 @@ export function formatRunFailureDetail(target) {
|
|||
const presentation = presentAgentRunFailure(target.event);
|
||||
const fields = [];
|
||||
const blocks = [];
|
||||
const actions = [];
|
||||
|
||||
if (presentation.code) {
|
||||
fields.push(field(tr('timelineDetailFieldErrorCode'), presentation.code));
|
||||
}
|
||||
fields.push(field(tr('timelineDetailFieldRetryable'), String(presentation.retryable)));
|
||||
fields.push(field(tr('timelineDetailFieldUserRetryable'), String(presentation.userRetryable)));
|
||||
addBlock(blocks, 'timelineResultText', presentation.message);
|
||||
if (presentation.technicalMessage && presentation.technicalMessage !== presentation.message) {
|
||||
addBlock(blocks, 'timelineTechnicalMessage', presentation.technicalMessage, DETAIL_TEXT_LIMIT, false, {
|
||||
|
|
@ -118,11 +120,21 @@ export function formatRunFailureDetail(target) {
|
|||
});
|
||||
}
|
||||
|
||||
if (presentation.userRetryable) {
|
||||
actions.push({
|
||||
kind: 'retry',
|
||||
labelKey: 'timelineActionRetry',
|
||||
hintKey: 'timelineActionRetryHint',
|
||||
icon: 'fa-rotate-right',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
labelKey: target.labelKey,
|
||||
path: '',
|
||||
fields,
|
||||
blocks,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -262,12 +262,36 @@ function createAgentRunTimelineApp() {
|
|||
}
|
||||
if (TERMINAL_EVENT_TYPES.includes(event.type)) {
|
||||
this.terminalEvent = event;
|
||||
if (event.type === 'run_failed' && event?.payload?.userRetryable === true) {
|
||||
this.revealUserRetryableFailure(event);
|
||||
}
|
||||
}
|
||||
if (this.detailsOpen && this.selectedSeq == null && isDisplayableRunEvent(event)) {
|
||||
void this.loadDetails();
|
||||
}
|
||||
this.$nextTick(() => this.stickToBottomIfNeeded());
|
||||
},
|
||||
revealUserRetryableFailure(event) {
|
||||
this.collapsed = false;
|
||||
this.selectedSeq = Number(event?.seq || 0) || this.selectedSeq;
|
||||
this.detailsOpen = true;
|
||||
this.$nextTick(() => {
|
||||
const pages = this.$refs.pages;
|
||||
if (pages instanceof HTMLElement) {
|
||||
pages.scrollTo({ left: pages.clientWidth, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
},
|
||||
invokeDetailAction(action) {
|
||||
if (!action || action.kind !== 'retry') {
|
||||
return;
|
||||
}
|
||||
const trigger = globalThis.jQuery || globalThis.$;
|
||||
if (typeof trigger !== 'function') {
|
||||
return;
|
||||
}
|
||||
trigger('#option_regenerate').trigger('click');
|
||||
},
|
||||
eventKey(event) {
|
||||
return event?.id ? `id:${event.id}` : `seq:${event?.runId || ''}:${event?.seq || 0}`;
|
||||
},
|
||||
|
|
@ -765,6 +789,20 @@ function createAgentRunTimelineApp() {
|
|||
<strong>{{ tr(section.labelKey) }}</strong>
|
||||
<small v-if="section.path">{{ section.path }}</small>
|
||||
</div>
|
||||
<div v-if="section.actions && section.actions.length" class="ttas-run-detail-actions">
|
||||
<button
|
||||
v-for="action in section.actions"
|
||||
:key="action.kind"
|
||||
type="button"
|
||||
class="menu_button ttas-run-detail-action"
|
||||
:data-ttas-action="action.kind"
|
||||
:title="action.hintKey ? tr(action.hintKey) : tr(action.labelKey)"
|
||||
@click.stop="invokeDetailAction(action)"
|
||||
>
|
||||
<i v-if="action.icon" class="fa-solid" :class="action.icon" aria-hidden="true"></i>
|
||||
<span>{{ tr(action.labelKey) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="section.fields && section.fields.length" class="ttas-run-detail-fields">
|
||||
<span v-for="field in section.fields" :key="field.label">
|
||||
<b>{{ field.label }}</b>
|
||||
|
|
|
|||
|
|
@ -983,6 +983,28 @@ body:is([data-generating="true"], [data-swiping="true"]) .ttas-agent-send-toggle
|
|||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.ttas-run-detail-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 7px 8px 2px;
|
||||
}
|
||||
|
||||
.ttas-run-detail-actions .ttas-run-detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ttas-run-detail-actions .ttas-run-detail-action[data-ttas-action="retry"] {
|
||||
border-color: color-mix(in srgb, var(--ttas-accent) 60%, transparent);
|
||||
color: var(--ttas-accent);
|
||||
}
|
||||
|
||||
.ttas-run-detail-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
const RUN_FAILURE_PRESENTATIONS = Object.freeze({
|
||||
'model.tool_call_required': Object.freeze({
|
||||
message: 'The model skipped the Agent tool flow and tried to answer directly. No chat message was committed. Try regenerating; if this keeps happening, reduce the context or use a model with stronger tool calling.',
|
||||
message: 'The model skipped the Agent tool flow and tried to answer directly. Any drift artifacts left in the chat were rolled back. Try regenerating; if this keeps happening, reduce the context or use a model with stronger tool calling.',
|
||||
messageKey: 'agent.error.model_tool_call_required.message',
|
||||
summary: 'The model skipped the Agent tool flow; no message was committed.',
|
||||
summary: 'The model skipped the Agent tool flow; drift artifacts rolled back.',
|
||||
summaryKey: 'agent.error.model_tool_call_required.summary',
|
||||
}),
|
||||
'agent.tool_after_finish': Object.freeze({
|
||||
message: 'The model requested more tools after workspace.finish, which breaks the Agent contract. Any partial chat output was rolled back. Try regenerating; if this persists, lower the temperature or pick a model that obeys workspace.finish.',
|
||||
messageKey: 'agent.error.tool_after_finish.message',
|
||||
summary: 'Model kept calling tools after workspace.finish; drift rolled back.',
|
||||
summaryKey: 'agent.error.tool_after_finish.summary',
|
||||
}),
|
||||
'agent.max_tool_rounds_exceeded': Object.freeze({
|
||||
message: 'The Agent loop exceeded the configured maximum tool rounds before calling workspace.finish. Any partial chat output was rolled back. Try regenerating with a tighter prompt, or raise the round budget in the profile.',
|
||||
messageKey: 'agent.error.max_tool_rounds_exceeded.message',
|
||||
summary: 'Tool round budget exhausted; drift rolled back.',
|
||||
summaryKey: 'agent.error.max_tool_rounds_exceeded.summary',
|
||||
}),
|
||||
});
|
||||
|
||||
export function presentAgentRunFailure(event) {
|
||||
|
|
@ -13,6 +25,11 @@ export function presentAgentRunFailure(event) {
|
|||
const message = String(payload.message || '').trim();
|
||||
const technicalMessage = String(payload.technicalMessage || message || runFailed()).trim();
|
||||
const presentation = RUN_FAILURE_PRESENTATIONS[code];
|
||||
const retryable = payload.retryable === true;
|
||||
// Backend guarantees retryable=true implies userRetryable=true. If the
|
||||
// backend omits userRetryable (older runtimes) fall back to retryable so
|
||||
// we never block a manual retry that auto-retry would have allowed.
|
||||
const userRetryable = payload.userRetryable === true || retryable;
|
||||
|
||||
return {
|
||||
code,
|
||||
|
|
@ -23,7 +40,8 @@ export function presentAgentRunFailure(event) {
|
|||
? translateAgentError(presentation.summary, presentation.summaryKey)
|
||||
: message || technicalMessage,
|
||||
technicalMessage,
|
||||
retryable: payload.retryable === true,
|
||||
retryable,
|
||||
userRetryable,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { presentAgentRunFailure } from './agent-error-presenter.js';
|
||||
import { rollbackAgentRunDriftMessages } from './agent-run-message-rollback.js';
|
||||
|
||||
const AGENT_RUN_STATE_CHANGED = 'tauritavern-agent-run-state-changed';
|
||||
const AGENT_RUN_EVENT = 'tauritavern-agent-run-event';
|
||||
const TERMINAL_EVENTS = new Set(['run_completed', 'run_cancelled', 'run_failed']);
|
||||
const ROLLBACK_EVENT_TYPE = 'run_rollback_targets';
|
||||
|
||||
let activeRun = null;
|
||||
let rollbackScriptOverride = null;
|
||||
|
||||
function requireAgentApi() {
|
||||
const agent = window.__TAURITAVERN__?.api?.agent;
|
||||
|
|
@ -38,9 +41,24 @@ function errorFromRunEvent(event) {
|
|||
error.userMessage = presentation.message;
|
||||
error.technicalMessage = presentation.technicalMessage;
|
||||
error.retryable = presentation.retryable;
|
||||
error.userRetryable = presentation.userRetryable;
|
||||
return error;
|
||||
}
|
||||
|
||||
// Lazy-load the SillyTavern vendor module so tests can inject a fake script
|
||||
// surface via __setAgentRunRollbackScriptForTests without dragging the whole
|
||||
// chat runtime into the unit-test sandbox.
|
||||
async function loadRollbackScript() {
|
||||
if (rollbackScriptOverride) {
|
||||
return rollbackScriptOverride;
|
||||
}
|
||||
return import('../../../script.js');
|
||||
}
|
||||
|
||||
export function __setAgentRunRollbackScriptForTests(script) {
|
||||
rollbackScriptOverride = script;
|
||||
}
|
||||
|
||||
export function getActiveAgentRun() {
|
||||
return activeRun;
|
||||
}
|
||||
|
|
@ -70,6 +88,12 @@ export async function startAndWaitForAgentRun(input) {
|
|||
|
||||
return new Promise((resolve, reject) => {
|
||||
let stop = () => {};
|
||||
// Drift artifacts surface on `run_rollback_targets` (level=Warn)
|
||||
// strictly before the matching `run_failed`. We start the rollback
|
||||
// immediately on the first event but only resolve the run promise
|
||||
// once it settles so vendor's finally(unblockGeneration) fires on
|
||||
// a chat that already matches the failed run's state.
|
||||
let pendingRollback = Promise.resolve();
|
||||
|
||||
const clearActiveRun = (lastEvent = null) => {
|
||||
activeRun = null;
|
||||
|
|
@ -80,21 +104,33 @@ export async function startAndWaitForAgentRun(input) {
|
|||
stop = agent.subscribe(handle.runId, (event) => {
|
||||
emitRunEvent(event);
|
||||
|
||||
if (event?.type === ROLLBACK_EVENT_TYPE) {
|
||||
pendingRollback = pendingRollback
|
||||
.then(() => handleRollbackEvent(handle.runId, event))
|
||||
.catch((rollbackError) => {
|
||||
console.error('[TauriTavern] Agent drift rollback failed', rollbackError);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TERMINAL_EVENTS.has(event?.type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
stop();
|
||||
clearActiveRun(event);
|
||||
const pending = pendingRollback;
|
||||
void pending.finally(() => {
|
||||
clearActiveRun(event);
|
||||
|
||||
if (event.type === 'run_failed') {
|
||||
reject(errorFromRunEvent(event));
|
||||
return;
|
||||
}
|
||||
if (event.type === 'run_failed') {
|
||||
reject(errorFromRunEvent(event));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
handle,
|
||||
terminalEvent: event,
|
||||
resolve({
|
||||
handle,
|
||||
terminalEvent: event,
|
||||
});
|
||||
});
|
||||
}, {
|
||||
onError(error) {
|
||||
|
|
@ -110,6 +146,15 @@ export async function startAndWaitForAgentRun(input) {
|
|||
});
|
||||
}
|
||||
|
||||
async function handleRollbackEvent(runId, event) {
|
||||
const targets = Array.isArray(event?.payload?.targets) ? event.payload.targets : [];
|
||||
if (targets.length === 0) {
|
||||
return;
|
||||
}
|
||||
const script = await loadRollbackScript();
|
||||
await rollbackAgentRunDriftMessages({ runId, targets, script });
|
||||
}
|
||||
|
||||
export function subscribeAgentRunState(listener) {
|
||||
const handler = (event) => listener(event.detail);
|
||||
window.addEventListener(AGENT_RUN_STATE_CHANGED, handler);
|
||||
|
|
|
|||
133
src/scripts/tauritavern/agent/agent-run-message-rollback.js
Normal file
133
src/scripts/tauritavern/agent/agent-run-message-rollback.js
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Rolls back chat artifacts an Agent run committed before it failed because
|
||||
// of instruction drift. The backend emits `run_rollback_targets` with the
|
||||
// host-assigned messageId (chat array index returned by
|
||||
// agent-chat-commit-bridge.js); per target we either:
|
||||
//
|
||||
// - pop just the swipe this run added to a pre-existing assistant message
|
||||
// (when the run's first commit was a regenerate / swipe), so prior swipes
|
||||
// the user authored are preserved; or
|
||||
// - delete the whole chat entry (when the run's first commit created a brand
|
||||
// new message).
|
||||
//
|
||||
// The strategy is recorded at commit time on
|
||||
// `extra.tauritavern.agent.rollback` so we never have to guess from the
|
||||
// runtime shape of `swipes`. Pre-rollback-strategy messages (older runs that
|
||||
// committed before this field existed) default to the deleteMessage path,
|
||||
// matching the previous behavior.
|
||||
//
|
||||
// Contract:
|
||||
// - `messageId` is the stringified chat array index the host returned to
|
||||
// `resolve_agent_chat_commit`. We never trust array order; we match by
|
||||
// `extra.tauritavern.agent.runId === runId` before touching anything so a
|
||||
// re-saved message from a later run is never wiped.
|
||||
// - Targets are deduped and processed back-to-front so chat indices stay
|
||||
// valid as messages are spliced out. (Swipe deletes don't shift indices,
|
||||
// but mixed runs / future targets might, so ordering stays defensive.)
|
||||
// - If the message has already been removed (e.g. the user deleted it
|
||||
// manually) or the swipe array is already gone, the target is silently
|
||||
// skipped — this is the expected no-op path.
|
||||
|
||||
const TARGETED_RUN_PATH = ['extra', 'tauritavern', 'agent', 'runId'];
|
||||
const ROLLBACK_META_PATH = ['extra', 'tauritavern', 'agent', 'rollback'];
|
||||
|
||||
export async function rollbackAgentRunDriftMessages({ runId, targets, script }) {
|
||||
const normalizedRunId = String(runId || '').trim();
|
||||
if (!normalizedRunId || !Array.isArray(targets) || targets.length === 0) {
|
||||
return { attempted: 0, deleted: 0, swipesRemoved: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
const chat = Array.isArray(script?.chat) ? script.chat : null;
|
||||
const deleteMessage = typeof script?.deleteMessage === 'function' ? script.deleteMessage : null;
|
||||
const deleteSwipe = typeof script?.deleteSwipe === 'function' ? script.deleteSwipe : null;
|
||||
if (!chat || !deleteMessage) {
|
||||
return { attempted: targets.length, deleted: 0, swipesRemoved: 0, skipped: targets.length };
|
||||
}
|
||||
|
||||
const uniqueIds = new Set();
|
||||
for (const target of targets) {
|
||||
const parsed = parseMessageId(target?.messageId);
|
||||
if (parsed !== null) {
|
||||
uniqueIds.add(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedIds = [...uniqueIds].sort((a, b) => b - a);
|
||||
let deleted = 0;
|
||||
let swipesRemoved = 0;
|
||||
let skipped = 0;
|
||||
for (const index of sortedIds) {
|
||||
if (index < 0 || index >= chat.length) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if (readNested(chat[index], TARGETED_RUN_PATH) !== normalizedRunId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const outcome = await rollbackOneMessage({
|
||||
index,
|
||||
message: chat[index],
|
||||
deleteSwipe,
|
||||
deleteMessage,
|
||||
});
|
||||
if (outcome === 'swipe') {
|
||||
swipesRemoved += 1;
|
||||
} else if (outcome === 'message') {
|
||||
deleted += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[TauriTavern] Failed to rollback Agent drift chat message', { index, runId: normalizedRunId, error });
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { attempted: targets.length, deleted, swipesRemoved, skipped };
|
||||
}
|
||||
|
||||
async function rollbackOneMessage({ index, message, deleteSwipe, deleteMessage }) {
|
||||
const rollback = readNested(message, ROLLBACK_META_PATH);
|
||||
if (
|
||||
rollback?.strategy === 'deleteSwipe'
|
||||
&& deleteSwipe
|
||||
&& Number.isInteger(rollback.swipeId)
|
||||
&& rollback.swipeId >= 0
|
||||
&& Array.isArray(message?.swipes)
|
||||
&& message.swipes.length > 1
|
||||
&& rollback.swipeId < message.swipes.length
|
||||
) {
|
||||
await deleteSwipe(rollback.swipeId, index);
|
||||
return 'swipe';
|
||||
}
|
||||
|
||||
await deleteMessage(index);
|
||||
return 'message';
|
||||
}
|
||||
|
||||
function parseMessageId(value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(text);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readNested(source, path) {
|
||||
let cursor = source;
|
||||
for (const key of path) {
|
||||
if (!cursor || typeof cursor !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
cursor = cursor[key];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
|
@ -11,6 +11,13 @@ export function attachHostCommitBridge({ runId, safeInvoke, readWorkspaceFile, s
|
|||
const state = {
|
||||
runId: normalizedRunId,
|
||||
messageId: null,
|
||||
// `createdMessage` and `firstSwipeId` are captured on the first commit so
|
||||
// a later `run_rollback_targets` can tell whether deleting the run means
|
||||
// removing the whole chat entry or only popping the swipe this run added
|
||||
// to a pre-existing assistant message (regenerate / swipe generation
|
||||
// types). See agent-run-message-rollback.js for the consumer.
|
||||
createdMessage: null,
|
||||
firstSwipeId: null,
|
||||
commitSeq: 0,
|
||||
resolvedCommitIds: new Set(),
|
||||
stop: null,
|
||||
|
|
@ -80,12 +87,20 @@ async function handleChatCommitRequested({ state, event, safeInvoke, readWorkspa
|
|||
const isFirstCommit = state.messageId == null;
|
||||
let messageId;
|
||||
if (isFirstCommit) {
|
||||
const lengthBefore = script.chat.length;
|
||||
await script.saveReply({
|
||||
type: initialCommitSaveType(payload.generationType, mode),
|
||||
getMessage: String(file?.text ?? ''),
|
||||
});
|
||||
messageId = getActiveMessageId(script.chat);
|
||||
state.messageId = messageId;
|
||||
// saveReply for type='swipe' / 'regenerate' against an existing
|
||||
// assistant message appends a new swipe in-place instead of pushing
|
||||
// a new chat entry. We snapshot which case we're in so rollback can
|
||||
// pop just the run's swipe (preserving prior swipes) rather than
|
||||
// wiping the entire message.
|
||||
state.createdMessage = script.chat.length > lengthBefore;
|
||||
state.firstSwipeId = state.createdMessage ? null : readMessageSwipeId(script.chat[messageId]);
|
||||
} else {
|
||||
messageId = Number(state.messageId);
|
||||
assertActiveAgentMessage(script.chat, messageId, state.runId);
|
||||
|
|
@ -96,7 +111,10 @@ async function handleChatCommitRequested({ state, event, safeInvoke, readWorkspa
|
|||
}
|
||||
|
||||
state.commitSeq += 1;
|
||||
mergeAgentCommitExtraIntoMessage(script.chat, messageId, payload, file, state.commitSeq);
|
||||
mergeAgentCommitExtraIntoMessage(script.chat, messageId, payload, file, state.commitSeq, {
|
||||
createdMessage: state.createdMessage,
|
||||
firstSwipeId: state.firstSwipeId,
|
||||
});
|
||||
await persistActiveChat(script);
|
||||
|
||||
await safeInvoke('resolve_agent_chat_commit', {
|
||||
|
|
@ -156,6 +174,11 @@ function getActiveMessageId(chat) {
|
|||
return chat.length - 1;
|
||||
}
|
||||
|
||||
function readMessageSwipeId(message) {
|
||||
const swipeId = Number(message?.swipe_id);
|
||||
return Number.isInteger(swipeId) && swipeId >= 0 ? swipeId : null;
|
||||
}
|
||||
|
||||
function assertActiveAgentMessage(chat, messageId, runId) {
|
||||
if (!Array.isArray(chat) || chat.length - 1 !== messageId) {
|
||||
throw new Error('agent.chat_commit_message_mismatch: this run can only update its active chat message');
|
||||
|
|
@ -170,7 +193,7 @@ function assertActiveAgentMessage(chat, messageId, runId) {
|
|||
}
|
||||
}
|
||||
|
||||
function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commitSeq) {
|
||||
function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commitSeq, runState = {}) {
|
||||
if (!Array.isArray(chat) || chat.length <= messageId) {
|
||||
throw new Error('agent.chat_commit_message_missing: active chat message is missing');
|
||||
}
|
||||
|
|
@ -192,6 +215,16 @@ function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commit
|
|||
bytes: file.bytes,
|
||||
sha256: file.sha256,
|
||||
};
|
||||
const rollback = (() => {
|
||||
// Default to deleting the whole message so messages persisted before
|
||||
// this field was introduced keep the old rollback behavior.
|
||||
const createdMessage = runState.createdMessage !== false;
|
||||
const swipeId = Number(runState.firstSwipeId);
|
||||
if (createdMessage || !Number.isInteger(swipeId) || swipeId < 0) {
|
||||
return { strategy: 'deleteMessage' };
|
||||
}
|
||||
return { strategy: 'deleteSwipe', swipeId };
|
||||
})();
|
||||
const extra = {
|
||||
tauritavern: {
|
||||
agent: {
|
||||
|
|
@ -206,6 +239,7 @@ function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commit
|
|||
commitId: payload.commitId,
|
||||
commitSeq,
|
||||
commits: [...previousCommits, commit],
|
||||
rollback,
|
||||
artifacts: [{
|
||||
path: file.path,
|
||||
target: 'message_body',
|
||||
|
|
|
|||
|
|
@ -787,3 +787,270 @@ test('Agent run detail formatter renders model turn display DTO', async () => {
|
|||
assert.equal(section.blocks[0].meta, 'reasoning_content · 30 bytes');
|
||||
assert.match(section.blocks[0].text, /Need to inspect/);
|
||||
});
|
||||
|
||||
test('Agent error presenter surfaces userRetryable from run_failed payload', async () => {
|
||||
const presenter = await importFresh('src/scripts/tauritavern/agent/agent-error-presenter.js');
|
||||
|
||||
const drift = presenter.presentAgentRunFailure({
|
||||
payload: {
|
||||
code: 'model.tool_call_required',
|
||||
message: 'low-level message',
|
||||
technicalMessage: 'Validation error: model.tool_call_required',
|
||||
retryable: false,
|
||||
userRetryable: true,
|
||||
},
|
||||
});
|
||||
assert.equal(drift.code, 'model.tool_call_required');
|
||||
assert.equal(drift.retryable, false);
|
||||
assert.equal(drift.userRetryable, true);
|
||||
assert.match(drift.message, /Agent tool flow/);
|
||||
|
||||
const transient = presenter.presentAgentRunFailure({
|
||||
payload: { code: 'transient', message: 'busy', retryable: true },
|
||||
});
|
||||
assert.equal(transient.retryable, true);
|
||||
assert.equal(transient.userRetryable, true);
|
||||
|
||||
const fatal = presenter.presentAgentRunFailure({
|
||||
payload: { code: 'agent.internal_error', message: 'boom', retryable: false },
|
||||
});
|
||||
assert.equal(fatal.userRetryable, false);
|
||||
});
|
||||
|
||||
test('Run failure detail surfaces a retry action and userRetryable field when allowed', async () => {
|
||||
const { formatRunFailureDetail } = await importFresh('src/scripts/extensions/agent-system/src/run-detail-format.js');
|
||||
|
||||
const drift = formatRunFailureDetail({
|
||||
labelKey: 'timelineErrorDetails',
|
||||
event: {
|
||||
payload: {
|
||||
code: 'agent.tool_after_finish',
|
||||
message: 'fatal',
|
||||
technicalMessage: 'Validation error: agent.tool_after_finish',
|
||||
retryable: false,
|
||||
userRetryable: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const userRetryableField = drift.fields.find(field => field.label === 'User-retryable');
|
||||
assert.ok(userRetryableField, 'user-retryable field must be present');
|
||||
assert.equal(userRetryableField.value, 'true');
|
||||
assert.deepEqual(drift.actions.map(action => action.kind), ['retry']);
|
||||
|
||||
const fatal = formatRunFailureDetail({
|
||||
labelKey: 'timelineErrorDetails',
|
||||
event: { payload: { code: 'agent.internal_error', message: 'boom', retryable: false } },
|
||||
});
|
||||
assert.deepEqual(fatal.actions, []);
|
||||
});
|
||||
|
||||
test('Agent run controller awaits rollback before rejecting on drift run_failed', async () => {
|
||||
let listener = null;
|
||||
installWindow({
|
||||
agent: {
|
||||
async startRunWithPromptSnapshot() {
|
||||
return { runId: 'run-drift' };
|
||||
},
|
||||
subscribe(_runId, callback) {
|
||||
listener = callback;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const controller = await importFresh('src/scripts/tauritavern/agent/agent-run-controller.js');
|
||||
const deletions = [];
|
||||
let resolveRollback;
|
||||
const rollbackGate = new Promise((resolve) => {
|
||||
resolveRollback = resolve;
|
||||
});
|
||||
controller.__setAgentRunRollbackScriptForTests({
|
||||
chat: [
|
||||
{},
|
||||
{ extra: { tauritavern: { agent: { runId: 'run-drift' } } } },
|
||||
],
|
||||
async deleteMessage(index) {
|
||||
deletions.push(index);
|
||||
await rollbackGate;
|
||||
},
|
||||
});
|
||||
|
||||
const run = controller.startAndWaitForAgentRun({ generationType: 'normal' });
|
||||
await Promise.resolve();
|
||||
|
||||
listener({
|
||||
seq: 1,
|
||||
runId: 'run-drift',
|
||||
type: 'run_rollback_targets',
|
||||
payload: {
|
||||
reasonCode: 'model.tool_call_required',
|
||||
targets: [{ path: 'output/main.md', mode: 'replace', messageId: '1', round: 1 }],
|
||||
},
|
||||
});
|
||||
listener({
|
||||
seq: 2,
|
||||
runId: 'run-drift',
|
||||
type: 'run_failed',
|
||||
payload: {
|
||||
code: 'model.tool_call_required',
|
||||
message: 'drift',
|
||||
retryable: false,
|
||||
userRetryable: true,
|
||||
},
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
void run.catch(() => { settled = true; });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, 'run must wait for rollback to complete');
|
||||
|
||||
resolveRollback();
|
||||
await assert.rejects(() => run, (error) => {
|
||||
assert.equal(error.userRetryable, true);
|
||||
assert.equal(error.retryable, false);
|
||||
assert.equal(error.agentErrorCode, 'model.tool_call_required');
|
||||
return true;
|
||||
});
|
||||
assert.deepEqual(deletions, [1]);
|
||||
controller.__setAgentRunRollbackScriptForTests(null);
|
||||
});
|
||||
|
||||
test('Rollback helper deletes drift messages back-to-front and skips foreign messages', async () => {
|
||||
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
|
||||
|
||||
const chat = [
|
||||
{ extra: { tauritavern: { agent: { runId: 'run-x' } } } },
|
||||
{ extra: { tauritavern: { agent: { runId: 'other-run' } } } },
|
||||
{ extra: { tauritavern: { agent: { runId: 'run-x' } } } },
|
||||
];
|
||||
const deletions = [];
|
||||
const script = {
|
||||
chat,
|
||||
async deleteMessage(index) {
|
||||
deletions.push(index);
|
||||
chat.splice(index, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await rollbackAgentRunDriftMessages({
|
||||
runId: 'run-x',
|
||||
targets: [
|
||||
{ messageId: '0' },
|
||||
{ messageId: '1' },
|
||||
{ messageId: '2' },
|
||||
{ messageId: '2' },
|
||||
{ messageId: 'invalid' },
|
||||
{ messageId: '99' },
|
||||
],
|
||||
script,
|
||||
});
|
||||
|
||||
assert.deepEqual(deletions, [2, 0]);
|
||||
assert.equal(result.deleted, 2);
|
||||
assert.ok(result.skipped >= 2);
|
||||
assert.equal(chat.length, 1);
|
||||
assert.equal(chat[0].extra.tauritavern.agent.runId, 'other-run');
|
||||
});
|
||||
|
||||
test('Rollback helper pops only the run-added swipe when the message pre-existed', async () => {
|
||||
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
|
||||
|
||||
const chat = [
|
||||
{ is_user: true, mes: 'hello' },
|
||||
{
|
||||
is_user: false,
|
||||
mes: 'agent drift attempt',
|
||||
swipe_id: 2,
|
||||
swipes: ['user-authored', 'user-authored alt 1', 'agent drift attempt'],
|
||||
swipe_info: [
|
||||
{ extra: {} },
|
||||
{ extra: {} },
|
||||
{ extra: { tauritavern: { agent: { runId: 'run-swipe' } } } },
|
||||
],
|
||||
extra: {
|
||||
tauritavern: {
|
||||
agent: {
|
||||
runId: 'run-swipe',
|
||||
rollback: { strategy: 'deleteSwipe', swipeId: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const swipeCalls = [];
|
||||
const messageDeletions = [];
|
||||
const script = {
|
||||
chat,
|
||||
async deleteSwipe(swipeId, messageId) {
|
||||
swipeCalls.push({ swipeId, messageId });
|
||||
chat[messageId].swipes.splice(swipeId, 1);
|
||||
chat[messageId].swipe_info.splice(swipeId, 1);
|
||||
chat[messageId].swipe_id = Math.min(swipeId, chat[messageId].swipes.length - 1);
|
||||
chat[messageId].mes = chat[messageId].swipes[chat[messageId].swipe_id];
|
||||
return chat[messageId].swipe_id;
|
||||
},
|
||||
async deleteMessage(index) {
|
||||
messageDeletions.push(index);
|
||||
chat.splice(index, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await rollbackAgentRunDriftMessages({
|
||||
runId: 'run-swipe',
|
||||
targets: [{ messageId: '1' }, { messageId: '1' }],
|
||||
script,
|
||||
});
|
||||
|
||||
assert.deepEqual(swipeCalls, [{ swipeId: 2, messageId: 1 }]);
|
||||
assert.deepEqual(messageDeletions, []);
|
||||
assert.equal(result.swipesRemoved, 1);
|
||||
assert.equal(result.deleted, 0);
|
||||
assert.equal(chat.length, 2, 'pre-existing message must be preserved');
|
||||
assert.equal(chat[1].mes, 'user-authored alt 1');
|
||||
assert.deepEqual(chat[1].swipes, ['user-authored', 'user-authored alt 1']);
|
||||
});
|
||||
|
||||
test('Rollback helper falls back to deleteMessage when swipe metadata is unsafe', async () => {
|
||||
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
|
||||
|
||||
const chat = [
|
||||
{
|
||||
is_user: false,
|
||||
swipes: ['only one'],
|
||||
swipe_id: 0,
|
||||
extra: {
|
||||
tauritavern: {
|
||||
agent: {
|
||||
runId: 'run-edge',
|
||||
rollback: { strategy: 'deleteSwipe', swipeId: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const swipeCalls = [];
|
||||
const messageDeletions = [];
|
||||
const script = {
|
||||
chat,
|
||||
async deleteSwipe(swipeId, messageId) {
|
||||
swipeCalls.push({ swipeId, messageId });
|
||||
},
|
||||
async deleteMessage(index) {
|
||||
messageDeletions.push(index);
|
||||
chat.splice(index, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const result = await rollbackAgentRunDriftMessages({
|
||||
runId: 'run-edge',
|
||||
targets: [{ messageId: '0' }],
|
||||
script,
|
||||
});
|
||||
|
||||
assert.deepEqual(swipeCalls, [], 'must not call deleteSwipe when only one swipe remains');
|
||||
assert.deepEqual(messageDeletions, [0]);
|
||||
assert.equal(result.deleted, 1);
|
||||
assert.equal(result.swipesRemoved, 0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue