feat: add automatic ACP session recovery with intelligent history truncation (#2711)
This commit is contained in:
@@ -397,7 +397,12 @@ function ACPClient:_create_stdio_transport()
|
|||||||
|
|
||||||
-- Read stderr for debugging
|
-- Read stderr for debugging
|
||||||
stderr:read_start(function(_, data)
|
stderr:read_start(function(_, data)
|
||||||
if data then vim.schedule(function() vim.notify("ACP stderr: " .. data, vim.log.levels.DEBUG) end) end
|
if data then
|
||||||
|
-- Filter out common session recovery error messages to avoid user confusion
|
||||||
|
if not (data:match("Session not found") or data:match("session/prompt")) then
|
||||||
|
vim.schedule(function() vim.notify("ACP stderr: " .. data, vim.log.levels.DEBUG) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -806,6 +806,83 @@ local function stop_retry_timer()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Intelligently truncate chat history for session recovery to avoid token limits
|
||||||
|
---@param history_messages table[]
|
||||||
|
---@return table[]
|
||||||
|
local function truncate_history_for_recovery(history_messages)
|
||||||
|
if not history_messages or #history_messages == 0 then return {} end
|
||||||
|
|
||||||
|
-- Get configuration parameters with validation and sensible defaults
|
||||||
|
local recovery_config = Config.session_recovery or {}
|
||||||
|
local MAX_RECOVERY_MESSAGES = math.max(1, math.min(recovery_config.max_history_messages or 10, 50))
|
||||||
|
local MAX_MESSAGE_LENGTH = math.max(100, math.min(recovery_config.max_message_length or 1000, 10000))
|
||||||
|
|
||||||
|
-- Keep recent messages starting from the newest
|
||||||
|
local truncated = {}
|
||||||
|
local count = 0
|
||||||
|
|
||||||
|
for i = #history_messages, 1, -1 do
|
||||||
|
if count >= MAX_RECOVERY_MESSAGES then break end
|
||||||
|
|
||||||
|
local message = history_messages[i]
|
||||||
|
if message and message.message and message.message.content then
|
||||||
|
-- Prioritize user messages and important assistant replies, skip verbose tool call results
|
||||||
|
local content = message.message.content
|
||||||
|
local role = message.message.role
|
||||||
|
|
||||||
|
-- Skip overly verbose tool call results with multiple code blocks
|
||||||
|
if
|
||||||
|
role == "assistant"
|
||||||
|
and type(content) == "string"
|
||||||
|
and content:match("```.*```.*```")
|
||||||
|
and #content > MAX_MESSAGE_LENGTH * 2
|
||||||
|
then
|
||||||
|
goto continue
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Handle string content
|
||||||
|
if type(content) == "string" then
|
||||||
|
if #content > MAX_MESSAGE_LENGTH then
|
||||||
|
-- Truncate overly long messages
|
||||||
|
local truncated_message = vim.deepcopy(message)
|
||||||
|
truncated_message.message.content = content:sub(1, MAX_MESSAGE_LENGTH) .. "...[truncated]"
|
||||||
|
table.insert(truncated, 1, truncated_message)
|
||||||
|
else
|
||||||
|
table.insert(truncated, 1, message)
|
||||||
|
end
|
||||||
|
-- Handle table content (multimodal messages)
|
||||||
|
elseif type(content) == "table" then
|
||||||
|
local truncated_message = vim.deepcopy(message)
|
||||||
|
-- Safely handle table content
|
||||||
|
if truncated_message.message.content and type(truncated_message.message.content) == "table" then
|
||||||
|
for j, item in ipairs(truncated_message.message.content) do
|
||||||
|
-- Handle various content item types
|
||||||
|
if type(item) == "string" and #item > MAX_MESSAGE_LENGTH then
|
||||||
|
truncated_message.message.content[j] = item:sub(1, MAX_MESSAGE_LENGTH) .. "...[truncated]"
|
||||||
|
elseif
|
||||||
|
type(item) == "table"
|
||||||
|
and item.text
|
||||||
|
and type(item.text) == "string"
|
||||||
|
and #item.text > MAX_MESSAGE_LENGTH
|
||||||
|
then
|
||||||
|
-- Handle {type="text", text="..."} format
|
||||||
|
item.text = item.text:sub(1, MAX_MESSAGE_LENGTH) .. "...[truncated]"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
table.insert(truncated, 1, truncated_message)
|
||||||
|
else
|
||||||
|
table.insert(truncated, 1, message)
|
||||||
|
end
|
||||||
|
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
::continue::
|
||||||
|
end
|
||||||
|
|
||||||
|
return truncated
|
||||||
|
end
|
||||||
---@param opts AvanteLLMStreamOptions
|
---@param opts AvanteLLMStreamOptions
|
||||||
function M._stream_acp(opts)
|
function M._stream_acp(opts)
|
||||||
Utils.debug("use ACP", Config.provider)
|
Utils.debug("use ACP", Config.provider)
|
||||||
@@ -1169,6 +1246,51 @@ function M._stream_acp(opts)
|
|||||||
end
|
end
|
||||||
acp_client:send_prompt(session_id, prompt, function(_, err_)
|
acp_client:send_prompt(session_id, prompt, function(_, err_)
|
||||||
if err_ then
|
if err_ then
|
||||||
|
-- ACP-specific session recovery: Check for session not found error
|
||||||
|
local recovery_config = Config.session_recovery or {}
|
||||||
|
local recovery_enabled = recovery_config.enabled ~= false -- Default enabled unless explicitly disabled
|
||||||
|
|
||||||
|
if
|
||||||
|
recovery_enabled
|
||||||
|
and err_.code == -32603
|
||||||
|
and err_.data
|
||||||
|
and err_.data.details == "Session not found"
|
||||||
|
and not rawget(opts, "_session_recovery_attempted")
|
||||||
|
then
|
||||||
|
-- Mark recovery attempt to prevent infinite loops
|
||||||
|
rawset(opts, "_session_recovery_attempted", true)
|
||||||
|
|
||||||
|
-- Clear invalid session ID
|
||||||
|
if opts.on_save_acp_session_id then
|
||||||
|
opts.on_save_acp_session_id("") -- Use empty string instead of nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Intelligently truncate history messages to avoid token limits
|
||||||
|
local original_history = opts.history_messages or {}
|
||||||
|
local truncated_history
|
||||||
|
|
||||||
|
-- Safely call truncation function
|
||||||
|
local ok, result = pcall(truncate_history_for_recovery, original_history)
|
||||||
|
if ok then
|
||||||
|
truncated_history = result
|
||||||
|
else
|
||||||
|
Utils.warn("Failed to truncate history for recovery: " .. tostring(result))
|
||||||
|
truncated_history = {} -- Use empty history as fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
opts.history_messages = truncated_history
|
||||||
|
|
||||||
|
Utils.info(
|
||||||
|
string.format(
|
||||||
|
"Session expired, recovering with %d recent messages (from %d total)...",
|
||||||
|
#truncated_history,
|
||||||
|
#original_history
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Retry with truncated history to rebuild context in new session
|
||||||
|
return M._stream_acp(opts)
|
||||||
|
end
|
||||||
opts.on_stop({ reason = "error", error = err_ })
|
opts.on_stop({ reason = "error", error = err_ })
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user