fix: keep the conversation when the agent list is re-polled
selectAgent cleared conversationId even when the agent hadn't changed, and the 60s inventory poll re-selects the persisted agent forever — so any message sent a minute after the last one opened a fresh conversation. 27 of them yesterday, four messages each. An empty curl body no longer counts as "no conversations".
This commit is contained in:
parent
e4d6ddcc72
commit
e4e659483b
1 changed files with 65 additions and 10 deletions
|
|
@ -44,6 +44,19 @@ Singleton {
|
||||||
property var agents: ({})
|
property var agents: ({})
|
||||||
property var agentList: Object.keys(agents)
|
property var agentList: Object.keys(agents)
|
||||||
property string currentAgentId: ""
|
property string currentAgentId: ""
|
||||||
|
// Auto-resume: whenever the active agent is (re)established — shell start,
|
||||||
|
// reboot, agent switch — re-attach her latest server-persisted conversation
|
||||||
|
// so the next message continues the thread instead of minting a fresh one.
|
||||||
|
// Without this every shell reload starts amnesiac, and the shell reloads
|
||||||
|
// often (a deploy, a lock, a crash). The server is the source of truth here;
|
||||||
|
// the surface deliberately keeps no agent → conversation map of its own.
|
||||||
|
// Gated so it never clobbers a live turn or an already-attached thread.
|
||||||
|
onCurrentAgentIdChanged: {
|
||||||
|
if (root.currentAgentId.length > 0 && root.serverUp
|
||||||
|
&& root.conversationId.length === 0 && !root.turnActive) {
|
||||||
|
root.resumeLatestConversation();
|
||||||
|
}
|
||||||
|
}
|
||||||
property string conversationId: ""
|
property string conversationId: ""
|
||||||
property bool turnActive: false
|
property bool turnActive: false
|
||||||
|
|
||||||
|
|
@ -163,11 +176,30 @@ Singleton {
|
||||||
|
|
||||||
function selectAgent(agentId) {
|
function selectAgent(agentId) {
|
||||||
if (!root.agents[agentId]) return false;
|
if (!root.agents[agentId]) return false;
|
||||||
|
// Re-affirming the agent that is already active is a no-op, and it has
|
||||||
|
// to be. Ai.qml re-selects the persisted agent on every agentsRefreshed,
|
||||||
|
// and the inventory poll above fires that once a minute, forever. While
|
||||||
|
// this function cleared conversationId unconditionally, every message
|
||||||
|
// sent more than a minute after the previous one opened a NEW
|
||||||
|
// conversation and therefore arrived with no history at all.
|
||||||
|
//
|
||||||
|
// Measured on the phone 2026-07-31: 27 conversations for one agent in a
|
||||||
|
// day, all but two exactly four messages long — system prompt, ambient,
|
||||||
|
// user, reply. One exchange each. That is the whole of the "she doesn't
|
||||||
|
// remember what I just said" report, and it is not the turn loop: the
|
||||||
|
// server assembles history from session.messages correctly, and there
|
||||||
|
// was simply never more than one exchange in a session to assemble.
|
||||||
|
//
|
||||||
|
// Switching agents is a decision. Polling is not.
|
||||||
|
if (agentId === root.currentAgentId) return true;
|
||||||
// A live turn belongs to the current conversation. Switching beneath
|
// A live turn belongs to the current conversation. Switching beneath
|
||||||
// it would render one agent's response in another agent's surface.
|
// it would render one agent's response in another agent's surface.
|
||||||
if (root.turnActive) return false;
|
if (root.turnActive) return false;
|
||||||
root.currentAgentId = agentId;
|
// Clear before the id changes, so onCurrentAgentIdChanged observes an
|
||||||
|
// empty conversation and re-attaches the incoming agent's own latest
|
||||||
|
// thread rather than leaving her on a blank one.
|
||||||
root.conversationId = "";
|
root.conversationId = "";
|
||||||
|
root.currentAgentId = agentId;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -184,25 +216,40 @@ Singleton {
|
||||||
property string agentId: ""
|
property string agentId: ""
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
|
if (listConversations.agentId !== root.currentAgentId) return;
|
||||||
|
// Empty stdout is a FAILED request, not an empty agent: curl -sf
|
||||||
|
// writes nothing on 4xx/5xx. Conflating the two clears
|
||||||
|
// conversationId and hands the surface an empty transcript to
|
||||||
|
// render, so one transient hiccup wipes the visible thread and
|
||||||
|
// orphans the live one. Only a parsed response is authoritative.
|
||||||
|
if (text.length === 0) {
|
||||||
|
console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
|
||||||
|
return;
|
||||||
|
}
|
||||||
let conversations = [];
|
let conversations = [];
|
||||||
try {
|
try {
|
||||||
conversations = text.length > 0 ? JSON.parse(text) : [];
|
conversations = JSON.parse(text);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Souveraine] Could not parse conversation list:", e);
|
console.log("[Souveraine] Could not parse conversation list:", e);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (listConversations.agentId !== root.currentAgentId) return;
|
|
||||||
if (conversations.length === 0) {
|
if (conversations.length === 0) {
|
||||||
|
// The server answered, and the answer is "none yet".
|
||||||
root.conversationId = "";
|
root.conversationId = "";
|
||||||
root.conversationResumed(root.currentAgentId, "", []);
|
root.conversationResumed(root.currentAgentId, "", []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Server sorts by updated_at descending — [0] is her latest.
|
||||||
root._loadConversation(listConversations.agentId, conversations[0].id);
|
root._loadConversation(listConversations.agentId, conversations[0].id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onExited: exitCode => {
|
onExited: exitCode => {
|
||||||
|
// A failed list fetch is transient (server busy, network blip).
|
||||||
|
// Do NOT clear conversationId — that orphans the live thread and
|
||||||
|
// forces the next send to mint a fresh conversation. Log and leave
|
||||||
|
// state alone; the next refresh or /resume retries.
|
||||||
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
|
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
|
||||||
root.conversationId = "";
|
console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place");
|
||||||
root.conversationResumed(root.currentAgentId, "", []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -213,21 +260,29 @@ Singleton {
|
||||||
property string requestedConversationId: ""
|
property string requestedConversationId: ""
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: {
|
onStreamFinished: {
|
||||||
|
if (loadConversation.agentId !== root.currentAgentId) return;
|
||||||
|
// Same rule as the list above: no body means the fetch failed.
|
||||||
|
// Attaching to the id anyway and announcing an empty transcript
|
||||||
|
// would blank the surface while claiming the thread is loaded.
|
||||||
|
if (text.length === 0) {
|
||||||
|
console.log("[Souveraine] empty transcript response — leaving current conversation in place");
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const messages = text.length > 0 ? JSON.parse(text) : [];
|
const messages = JSON.parse(text);
|
||||||
if (loadConversation.agentId !== root.currentAgentId) return;
|
|
||||||
root.conversationId = loadConversation.requestedConversationId;
|
root.conversationId = loadConversation.requestedConversationId;
|
||||||
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
|
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("[Souveraine] Could not parse conversation transcript:", e);
|
console.log("[Souveraine] Could not parse conversation transcript:", e);
|
||||||
root.conversationResumed(root.currentAgentId, "", []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onExited: exitCode => {
|
onExited: exitCode => {
|
||||||
|
// Transient failure (e.g. a 5xx on GET /messages). Don't clear —
|
||||||
|
// see listConversations.onExited. Leaving conversationId alone keeps
|
||||||
|
// an already-attached thread reachable instead of forcing a new one.
|
||||||
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
|
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
|
||||||
root.conversationId = "";
|
console.log("[Souveraine] conversation transcript fetch failed (exit " + exitCode + ") — leaving current conversation in place");
|
||||||
root.conversationResumed(root.currentAgentId, "", []);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue