fix(chats): retry a failed turn from where it stopped

The Retry button of the failure card called regenerate, which cut the
transcript back to the user's message and ran it again. Every message,
tool call and result the failed run had produced was lost, on the
server and on the page.

Add POST /api/chats/{id}/resume. It starts a turn from the transcript
as it is: nothing is removed, the model keeps its own saved state (so
finished tool calls are not repeated and a compaction summary stays),
messages that waited are delivered first, and the model gets a note to
go on instead of the question again. It does nothing when the chat is
not at a failed turn or a turn runs, so a double click starts one run.

The reason of a failure is now kept as a transcript line, so a reload
shows the same card text as the live page. The card stands after the
kept messages.

Assisted-by: Claude:claude-sonnet-5-5
This commit is contained in:
Ettore Di Giacinto committed 2026-11-05 11:26:42 +00:00
1 parent 1092707cf4
commit 528ce11c63
24 files changed
+825 -26

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ var RouteFeatureRegistry = []RouteFeature{
{"GET", "/api/chats/search", FeatureChats},
{"POST", "/api/chats/import", FeatureChats},
{"POST", "/api/chats/:id/regenerate", FeatureChats},
{"POST", "/api/chats/:id/resume", FeatureChats},
{"POST", "/api/chats/:id/messages/:seq/edit", FeatureChats},
{"POST", "/api/chats/:id/fork", FeatureChats},
{"GET", "/api/chats/:id/export", FeatureChats},
File diff suppressed because one or more lines are too long.
@@ -101,6 +101,21 @@ func (h *Handlers) Regenerate(c echo.Context) error {
return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID})
}
// Resume goes on with the turn that failed.
// @Summary Go on with the turn that failed.
// @Description Starts a new turn that goes on from where the last one failed. The transcript stays as it is, with the messages, tool calls and results of the failed turn and the reason at its end; the model keeps its own state, so a tool call that has its result is not made again. Messages that wait for the chat go first. When the chat is not at a failed turn, or a turn runs, nothing starts and started is false, so a second click is harmless.
// @Tags chats
// @Param id path string true "conversation id"
// @Success 200 {object} schema.ChatsSendResponse "Whether a turn started"
// @Router /api/chats/{id}/resume [post]
func (h *Handlers) Resume(c echo.Context) error {
res, err := h.svc.Resume(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failFor(c, err)
}
return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID})
}
// EditMessage edits a sent message and runs it again.
// @Summary Edit a message you sent and run the chat again from it.
// @Description Replaces the text of your message with the given sequence number, removes everything after it, and starts a new turn. 409 while a turn runs; 400 when the line is not one of your messages.
+4
View File
@@ -435,6 +435,10 @@ func messageList(principal string, entries []session.Entry) schema.ChatsMessageL
if e.Role == session.RoleGoal {
m.GoalEvent, m.GoalTurn = e.Note, e.Rev
}
if e.Role == session.RoleFailure {
f := session.ParseFailure(e)
m.Failure = &schema.ChatsFailure{Code: f.Code, Model: f.Model, RetryAfter: f.RetryAfter, Detail: f.Detail}
}
out.Messages = append(out.Messages, m)
}
return out
@@ -0,0 +1,107 @@
import { test, expect } from './coverage-fixtures.js'
import { mockChat } from './chat-fixtures.js'
// A long turn that fails part way: the card stands after the kept transcript,
// and Retry goes on from there. The chats API is stubbed with a small state
// machine, so the transcript before and after the click can be compared.
const AT = '2026-11-05T10:00:00Z'
const line = (seq, role, text, extra = {}) => ({ seq, turn: 1, role, text, at: AT, ...extra })
const FAILED_TRANSCRIPT = [
line(1, 'user', 'build the quarterly report'),
line(2, 'assistant', 'I will read the three source files first.'),
line(3, 'tool', 'read_file: sales.csv\n240 rows'),
line(4, 'assistant', 'The sales file has 240 rows. Now the costs.'),
line(5, 'tool', 'read_file: costs.csv\n180 rows'),
line(6, 'failure', 'The provider behind the model failed several times in a row (HTTP 502), so the turn was stopped.', {
failure: { code: 'upstream', detail: 'the model failed 3 times in a row (HTTP 502)' },
}),
line(7, 'checkpoint', ''),
]
function stubChat(page) {
const state = { messages: [...FAILED_TRANSCRIPT], running: false, last_turn: 'failed', calls: [] }
const detail = () => ({
id: 'c1', title: 'Quarterly report', model: 'qwen3-8b', kind: 'chat', created: AT, updated: new Date().toISOString(),
running: state.running, last_turn: state.last_turn, pending: [],
})
return {
state,
async install() {
await page.route('**/api/chats/c1/events', route => route.abort())
await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => route.fulfill({ json: { messages: state.messages } }))
await page.route('**/api/chats/c1/resume', async route => {
state.calls.push('resume')
// The server goes on from the failed turn: nothing is removed, the
// rest of the work is added after the kept lines.
state.messages = [
...state.messages.filter(m => m.role !== 'checkpoint'),
line(8, 'assistant', 'The costs file has 180 rows. The report is ready: revenue is up 12 percent.'),
line(9, 'checkpoint', ''),
]
state.last_turn = 'done'
await route.fulfill({ json: { started: true } })
})
await page.route('**/api/chats/c1/regenerate', async route => {
state.calls.push('regenerate')
await route.fulfill({ json: { started: true } })
})
await page.route('**/api/chats/c1', route => route.fulfill({ json: detail() }))
await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({
json: { conversations: [{ ...detail(), pending: undefined }], persistent: true, files: true, files_reason: '' },
}))
},
}
}
test.describe('Retry of a failed turn', () => {
test.beforeEach(async ({ page }) => { await mockChat(page) })
test('the card stands after the kept transcript and says what failed', async ({ page }) => {
// Tall enough to show the whole transcript in the screenshot.
await page.setViewportSize({ width: 1280, height: 1100 })
const api = stubChat(page)
await api.install()
await page.goto('/app/chats/c1')
// Read from the server after a plain load: no live event was seen.
await expect(page.getByText('The sales file has 240 rows. Now the costs.')).toBeVisible()
const card = page.getByTestId('chat-error')
await expect(card).toBeVisible()
await expect(card).toHaveAttribute('data-code', 'upstream')
await expect(page.getByTestId('chat-error-reason')).toContainText('The provider behind the model returned an error.')
await expect(page.getByTestId('chat-error-reason')).toContainText('What was written so far is kept above.')
await expect(card.getByRole('button', { name: 'Retry' })).toBeEnabled()
// The card comes after the last kept message, not before.
const order = await page.evaluate(() => {
const last = [...document.querySelectorAll('[data-testid="chat-message"]')].pop()
const err = document.querySelector('[data-testid="chat-error"]')
return last && err ? Boolean(last.compareDocumentPosition(err) & Node.DOCUMENT_POSITION_FOLLOWING) : null
})
expect(order).toBe(true)
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await page.screenshot({ path: process.env.RETRY_SHOTS ? `${process.env.RETRY_SHOTS}/failure-card-kept-transcript.png` : 'test-results/failure-card-kept-transcript.png' })
})
test('Retry goes on without removing a line, and a double click is one call', async ({ page }) => {
const api = stubChat(page)
await api.install()
await page.goto('/app/chats/c1')
await expect(page.getByTestId('chat-error')).toBeVisible()
await page.setViewportSize({ width: 1280, height: 1100 })
await page.getByTestId('chat-error').getByRole('button', { name: 'Retry' }).dblclick()
await expect(page.getByText('The report is ready: revenue is up 12 percent.')).toBeVisible()
await expect(page.getByTestId('chat-error')).toHaveCount(0)
// Everything the failed turn did is still on the page, once.
await expect(page.getByText('I will read the three source files first.')).toHaveCount(1)
await expect(page.getByText('The sales file has 240 rows. Now the costs.')).toHaveCount(1)
await expect(page.getByText('build the quarterly report')).toHaveCount(1)
expect(api.state.calls).toEqual(['resume'])
await page.screenshot({ path: process.env.RETRY_SHOTS ? `${process.env.RETRY_SHOTS}/after-retry.png` : 'test-results/after-retry.png' })
})
})
@@ -25,7 +25,8 @@ function Prose({ html, className = '' }) {
}
// A turn that failed: the reason in plain words, what to do about it, a Retry
// that sends the last question again and, apart, the cleaned technical detail.
// that goes on from where the turn stopped (the lines before it stay) and,
// apart, the cleaned technical detail.
// failure says why (see utils/chatFailure.js); without it the card still shows
// that the turn failed and offers the Retry.
export function ErrorCard({ failure, isAdmin, kept, onRetry, canRetry }) {
+24 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { chatsApi, chatLoopsApi } from '../utils/api'
import { eventPayload, retryDelay } from '../utils/streamRetry'
import { emptyLive, toolStart, toolEnd, cardResult, cardFromMessages, liveCardWanted, consentAsk } from '../utils/chatLive'
import { notifyTurnEnd } from './useWorkspaceFeed'
import { pushUsage } from '../utils/chatFoot'
import { savedFailure } from '../utils/chatFailure'
// Every event kind the server sends. EventSource needs one listener per name.
const EVENT_KINDS = [
@@ -434,6 +435,26 @@ export function useServerChat({ seed = null } = {}) {
await refreshChat(id)
}, [refreshChat, setError])
const regenerate = useCallback((id) => restart(id, () => chatsApi.regenerate(id)), [restart])
// Retry of a failed turn: the server goes on from where it stopped and
// removes nothing. The guard makes a double click one call; the server
// answers started: false to a second one anyway (another window).
const [resuming, setResuming] = useState(false)
const resumingRef = useRef(false)
const resume = useCallback(async (id) => {
if (resumingRef.current) return
resumingRef.current = true
setResuming(true)
try {
await restart(id, () => chatsApi.resume(id))
} finally {
resumingRef.current = false
setResuming(false)
}
}, [restart])
// Why the last turn failed, as the transcript keeps it (a reload has no
// live event).
const failureSaved = useMemo(() => savedFailure(messages), [messages])
const editMessage = useCallback((id, seq, text) => restart(id, () => chatsApi.editMessage(id, seq, text)), [restart])
// The context commands. They answer 409 while a turn runs; the caller
@@ -457,7 +478,7 @@ export function useServerChat({ seed = null } = {}) {
}, [refreshList, select])
return {
chats, persistent, imageModel, files, activeId, detail, messages, live, notes, error, failure, loading, usage, usageHistory, firstTokenMs, readPace, ticks, pausedSteps,
chats, persistent, imageModel, files, activeId, detail, messages, live, notes, error, failure, failureSaved, resuming, loading, usage, usageHistory, firstTokenMs, readPace, ticks, pausedSteps,
running: Boolean(detail?.running),
lastTurn: detail?.last_turn || '',
pending: detail?.pending || [],
@@ -467,7 +488,7 @@ export function useServerChat({ seed = null } = {}) {
// The command that waits for a yes, as the server keeps it with the chat: a
// reload shows the card again. Null when none waits.
hostConsent: detail?.host_consent ? { chatId: detail.id, id: detail.host_consent.id, command: detail.host_consent.command || '' } : null,
select, createChat, send, editPending, cancelPending, stop, sendNow, startLoop, setLoopState, stopLoop, stopLoops, remove, rename, update, regenerate, editMessage, refreshList, compact, clear,
select, createChat, send, editPending, cancelPending, stop, sendNow, startLoop, setLoopState, stopLoop, stopLoops, remove, rename, update, regenerate, resume, editMessage, refreshList, compact, clear,
setGoal, clearGoal, resumeGoal, pauseGoal,
clearError: () => setError(''),
}
+12 -5
View File
@@ -53,7 +53,7 @@ import SubAgentsMenu from '../components/chat/SubAgentsMenu'
import { EmptyHead, EmptyUnder } from '../components/chat/EmptyChat'
// eslint-disable-next-line no-unused-vars
import ShortcutsRow from '../components/wellknown/ShortcutsRow'
import { isStaleFailure } from '../utils/chatFailure'
import { isStaleFailure, keptAbove } from '../utils/chatFailure'
import { isFolderMissingError } from '../utils/chatAgents'
// eslint-disable-next-line no-unused-vars
import CommandsConsentCard from '../components/chat/CommandsConsentCard'
@@ -619,6 +619,12 @@ function ChatPage() {
if (activeId) chat.regenerate(activeId)
}, [activeId, chat])
// Retry of the failure card goes on from where the turn stopped: the
// messages, tool calls and results stay.
const handleRetry = useCallback(() => {
if (activeId) chat.resume(activeId)
}, [activeId, chat])
// Branch copies the chat up to and including the line into a new chat, which opens.
const handleFork = useCallback(async (seq) => {
if (!activeId) return
@@ -1038,6 +1044,7 @@ function ChatPage() {
// page was reloaded) fades after a day, so an old failure does not look new.
const showFailure = Boolean(activeId) && !chat.running && chat.lastTurn === 'failed'
&& (Boolean(chat.failure) || !isStaleFailure(chat.detail?.updated))
const failureShown = chat.failure || chat.failureSaved
// The one state of the status line, and whether the keyboard hint shows:
// only while the composer has focus, is empty and the chat is idle.
const footState = chatFootState({
@@ -1165,11 +1172,11 @@ function ChatPage() {
{showFailure && (
<ErrorCard
failure={chat.failure}
failure={failureShown}
isAdmin={isAdmin}
kept={false}
canRetry={!!lastUserText(messages)}
onRetry={handleRegenerate}
kept={keptAbove(messages)}
canRetry={!chat.resuming}
onRetry={handleRetry}
/>
)}
+4
View File
@@ -151,6 +151,10 @@ export const chatsApi = {
// turn runs; settings apply from the next turn.
update: (id, fields) => fetchJSON(API_CONFIG.endpoints.chat(id), { method: 'PATCH', body: JSON.stringify(fields) }),
// Regenerate and edit start a new turn; both answer 409 while one runs.
// Resume goes on from where a failed turn stopped; nothing is removed. It
// answers started: false when the chat is not at a failed turn (a second
// click, or another window resumed it).
resume: (id) => postJSON(`${API_CONFIG.endpoints.chat(id)}/resume`, {}),
regenerate: (id) => postJSON(`${API_CONFIG.endpoints.chat(id)}/regenerate`, {}),
editMessage: (id, seq, text) => postJSON(`${API_CONFIG.endpoints.chat(id)}/messages/${Number(seq)}/edit`, { text }),
fork: (id, fromSeq, title) => postJSON(`${API_CONFIG.endpoints.chat(id)}/fork`, {
+27
View File
@@ -67,3 +67,30 @@ export function isStaleFailure(updated, now = Date.now()) {
const at = Date.parse(updated || '')
return Number.isFinite(at) && now - at > STALE_FAILURE_MS
}
// savedFailure reads why the last turn failed from the transcript, where the
// server keeps it: the line that ends the failed turn is the last one before
// the checkpoint. It is how a reload shows the same reason as the live page.
// Null when the transcript does not end on a failure line.
export function savedFailure(messages) {
for (let i = (messages?.length || 0) - 1; i >= 0; i--) {
const m = messages[i]
if (m.role === 'checkpoint') continue
if (m.role !== 'failure') return null
const f = m.failure || {}
return { code: f.code || 'agent', model: f.model || '', retry: f.retry_after || 0, detail: f.detail || '', text: m.text || '' }
}
return null
}
// keptAbove says whether the failed turn left something to read: a model
// message or a tool call after the last message of the person. The banner then
// says that what was written so far stays.
export function keptAbove(messages) {
for (let i = (messages?.length || 0) - 1; i >= 0; i--) {
const r = messages[i].role
if (r === 'user') return false
if (r === 'assistant' || r === 'tool') return true
}
return false
}
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { failureView, isStaleFailure, STALE_FAILURE_MS } from '../../src/utils/chatFailure.js'
import { failureView, isStaleFailure, savedFailure, keptAbove, STALE_FAILURE_MS } from '../../src/utils/chatFailure.js'
const locales = new URL('../../public/locales/', import.meta.url)
const load = (lng) => JSON.parse(readFileSync(new URL(`${lng}/chat.json`, locales), 'utf8'))
@@ -82,3 +82,23 @@ test('chat failure: an old failure seen after a reload is stale, a recent one is
assert.equal(isStaleFailure(new Date(now - STALE_FAILURE_MS - 1000).toISOString(), now), true)
assert.equal(isStaleFailure('', now), false)
})
test('chat failure: the reason the server keeps in the transcript is read back for a reload', () => {
const messages = [
{ role: 'user', text: 'build the report' },
{ role: 'assistant', text: 'step one' },
{ role: 'failure', text: 'The provider failed.', failure: { code: 'upstream', model: 'm', retry_after: 3, detail: 'HTTP 502' } },
{ role: 'checkpoint', text: '' },
]
assert.deepEqual(savedFailure(messages), { code: 'upstream', model: 'm', retry: 3, detail: 'HTTP 502', text: 'The provider failed.' })
// A transcript that went on after the failure has no failure to show.
assert.equal(savedFailure([...messages, { role: 'user', text: 'again' }]), null)
assert.equal(savedFailure([]), null)
assert.equal(savedFailure(undefined), null)
})
test('chat failure: the banner says what stays only when the failed turn produced something', () => {
assert.equal(keptAbove([{ role: 'user' }, { role: 'failure' }]), false)
assert.equal(keptAbove([{ role: 'user' }, { role: 'assistant' }, { role: 'tool' }, { role: 'failure' }]), true)
assert.equal(keptAbove([{ role: 'assistant' }, { role: 'user' }, { role: 'failure' }]), false, 'only the last turn counts')
})
+1
View File
@@ -268,6 +268,7 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend
e.GET(base+"/:id", h.Get)
e.PATCH(base+"/:id", h.Update)
e.POST(base+"/:id/regenerate", h.Regenerate)
e.POST(base+"/:id/resume", h.Resume)
e.POST(base+"/:id/messages/:seq/edit", h.EditMessage)
e.POST(base+"/:id/fork", h.Fork)
e.GET(base+"/:id/export", h.Export)
+49 -1
View File
@@ -34,6 +34,15 @@ type fakeLLM struct {
reqs []map[string]any
// hold, when set, makes a request wait until it is closed or sent a token.
hold chan struct{}
// failing, when set, answers every request with a 502, as a provider
// behind a proxy does when it is down.
failing bool
}
func (f *fakeLLM) setFailing(v bool) {
f.mu.Lock()
f.failing = v
f.mu.Unlock()
}
func (f *fakeLLM) requests() []map[string]any {
@@ -79,8 +88,12 @@ func (f *fakeLLM) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
f.mu.Lock()
f.reqs = append(f.reqs, req)
hold := f.hold
hold, failing := f.hold, f.failing
f.mu.Unlock()
if failing {
w.WriteHeader(http.StatusBadGateway)
return
}
if hold != nil {
select {
case <-hold:
@@ -337,6 +350,41 @@ var _ = Describe("Chats transcript edits", func() {
Expect(userTexts(llm.last())).To(Equal([]string{"first question", "second question"}))
})
It("retries a failed turn from the transcript, with nothing removed", func() {
llm.setFailing(true)
Expect(s.do("alice", http.MethodPost, "/api/chats/"+id+"/messages", map[string]any{"text": "third question"}).Code).To(Equal(http.StatusOK))
s.waitIdle("alice", id)
Expect(s.detail("alice", id)["last_turn"]).To(Equal("failed"))
failed := s.transcript("alice", id)
Expect(roleTexts(failed, "user")).To(Equal([]string{"first question", "second question", "third question"}))
var failure map[string]any
for _, m := range failed {
if m["role"] == "failure" {
failure = m["failure"].(map[string]any)
}
}
Expect(failure).To(HaveKeyWithValue("code", "upstream"), "the reason is kept with the chat for a reload")
llm.setFailing(false)
rec := s.do("alice", http.MethodPost, "/api/chats/"+id+"/resume", nil)
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(decodeJSON(rec)).To(HaveKeyWithValue("started", true))
s.waitIdle("alice", id)
Expect(s.detail("alice", id)["last_turn"]).To(Equal("done"))
msgs := s.transcript("alice", id)
Expect(roleTexts(msgs, "user")).To(Equal([]string{"first question", "second question", "third question"}), "the question is not asked twice")
Expect(roleTexts(msgs, "assistant")).To(HaveLen(3))
// The model saw the three questions once each, and the note to go on.
Expect(userTexts(llm.last())).To(ContainElement(ContainSubstring("Go on from where you stopped")))
Expect(userTexts(llm.last())).To(ContainElements("first question", "second question", "third question"))
// A second click does nothing.
again := s.do("alice", http.MethodPost, "/api/chats/"+id+"/resume", nil)
Expect(again.Code).To(Equal(http.StatusOK))
Expect(decodeJSON(again)).To(HaveKeyWithValue("started", false))
Expect(s.do("bob", http.MethodPost, "/api/chats/"+id+"/resume", nil).Code).To(Equal(http.StatusNotFound))
})
It("edits a sent message and runs the chat again from there", func() {
var firstSeq float64
for _, m := range s.transcript("alice", id) {
+19 -4
View File
@@ -420,7 +420,7 @@ type ChatsPendingEdit struct {
type ChatsMessage struct {
Seq int64 `json:"seq"`
Turn int `json:"turn"`
Role string `json:"role"` // user, assistant, tool, checkpoint, reasoning, compacted, cleared or goal
Role string `json:"role"` // user, assistant, tool, checkpoint, reasoning, compacted, cleared, goal or failure
// Text is what was written. For a user line it is the text you typed:
// the files you attached are in Attachments.
Text string `json:"text"`
@@ -450,9 +450,24 @@ type ChatsMessage struct {
// server sent by itself to go on; Text is that message and GoalTurn its
// automatic turn), paused (Text is the reason), resumed, done (Text is the
// summary), blocked (Text is the reason) or cleared.
GoalEvent string `json:"goal_event,omitempty"`
GoalTurn int64 `json:"goal_turn,omitempty"`
At time.Time `json:"at"`
GoalEvent string `json:"goal_event,omitempty"`
GoalTurn int64 `json:"goal_turn,omitempty"`
// Failure is set on a failure line: why the turn ended (Text is the reason
// in English).
Failure *ChatsFailure `json:"failure,omitempty"`
At time.Time `json:"at"`
}
// ChatsFailure is the cause of a turn that ended on an error.
type ChatsFailure struct {
// Code names the cause, such as model_not_found, upstream, timeout or agent.
Code string `json:"code"`
// Model is the model the cause is about, when it is known.
Model string `json:"model,omitempty"`
// RetryAfter is the seconds until a model in cooldown loads again.
RetryAfter int `json:"retry_after,omitempty"`
// Detail is the error text, cleaned, for the technical view.
Detail string `json:"detail,omitempty"`
}
// ChatsToolCard points the interface at a record that belongs to a tool result.
+40 -1
View File
@@ -95,6 +95,45 @@ func (s *Service) Regenerate(ctx context.Context, user, conv string) (SendResult
})
}
// Resume goes on with a turn that failed, from where it stopped. The
// transcript stays as it is, with the steps the failed turn finished and the
// reason at its end; the model keeps its own state, so a tool call that has
// its result is not made again and a summary of the context stays. Messages
// that wait for the chat go first, as in any new turn. It does nothing, and
// says so with Started false, when the chat is not at a failed turn: another
// click or another window may have resumed it already, and a second run would
// answer the same failure twice.
func (s *Service) Resume(ctx context.Context, user, conv string) (SendResult, error) {
p := principal(user)
rec, err := s.d.Store.GetChat(ctx, p, conv)
if err != nil {
return SendResult{}, err
}
if rec.Kind == session.KindSubAgent {
return SendResult{}, ErrSubAgentReadOnly
}
unlock := s.lock(key(p, conv))
defer unlock()
info, err := s.d.Store.Turn(ctx, p, conv)
if err != nil {
return SendResult{}, err
}
if info.Running || info.Last != store.TurnFailed {
return SendResult{}, nil
}
runID := newID()
if err := s.d.Store.ClaimTurn(ctx, p, conv, runID); err != nil {
if errors.Is(err, store.ErrTurnRunning) {
return SendResult{}, nil
}
return SendResult{}, err
}
if err := s.startClaimed(ctx, p, conv, runID, session.Message{Resume: true}); err != nil {
return SendResult{}, err
}
return SendResult{Started: true}, nil
}
// EditAndRerun replaces the user message with sequence number seq by text,
// removes everything after it and starts a new turn.
func (s *Service) EditAndRerun(ctx context.Context, user, conv string, seq int64, text string) (SendResult, error) {
@@ -221,7 +260,7 @@ func (s *Service) Fork(ctx context.Context, user, conv string, upTo int64, title
}
var kept []session.Entry
for _, e := range entries {
if (upTo == 0 || e.Seq <= upTo) && e.Role != session.RoleCheckpoint && e.Role != session.RoleGoal {
if (upTo == 0 || e.Seq <= upTo) && e.Role != session.RoleCheckpoint && e.Role != session.RoleGoal && e.Role != session.RoleFailure {
kept = append(kept, e)
}
}
+261
View File
@@ -0,0 +1,261 @@
package studio_test
import (
"context"
"errors"
"slices"
"strings"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/session"
"github.com/mudler/LocalAI/core/services/studio/store"
"github.com/mudler/LocalAI/core/services/studio/studiotest"
"github.com/mudler/LocalAI/core/services/studio/workspace"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// shape reduces a transcript to what a reader sees, without the sequence
// numbers and the checkpoints, so two reads can be compared.
func shape(es []session.Entry) []string {
var out []string
for _, e := range es {
if e.Role != session.RoleCheckpoint {
out = append(out, e.Role+": "+e.Text)
}
}
return out
}
// failsAtLastStep scripts a long turn that finishes two steps and then fails,
// the way a provider that errors on its Nth request does. The turn that goes
// on finishes the work. calls collects what the model was sent.
func failsAtLastStep(calls *[]string) func(workspace.Actor, string) []studiotest.ToolStep {
failed := false
return func(_ workspace.Actor, text string) []studiotest.ToolStep {
*calls = append(*calls, text)
if text == "build the report" && !failed {
failed = true
return []studiotest.ToolStep{
{Say: "step one"}, {Tool: "search", Args: `{"q":"one"}`},
{Say: "step two"}, {Tool: "search", Args: `{"q":"two"}`},
{Err: errors.New("the model failed 3 times in a row (HTTP 502)")},
}
}
return []studiotest.ToolStep{{Say: "all done"}}
}
}
var _ = Describe("retry of a failed turn", func() {
ctx := context.Background()
It("goes on from the last complete step and keeps the whole transcript", func() {
var calls []string
svc, h := newToolService(failsAtLastStep(&calls))
id := create(svc, alice)
sent(svc, alice, id, "build the report")
idle(svc, alice, id)
Expect(turn(svc, alice, id).Last).To(Equal(store.TurnFailed))
failed := transcript(svc, alice, id)
Expect(shape(failed)[:5]).To(Equal([]string{
"user: build the report",
"assistant: step one", "tool: search: \nfound",
"assistant: step two", "tool: search: \nfound",
}))
res, err := svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
Expect(res.Started).To(BeTrue())
idle(svc, alice, id)
Expect(turn(svc, alice, id).Last).To(Equal(store.TurnDone))
after := transcript(svc, alice, id)
// Every line of the failed turn is still there, in place, with the
// same sequence number.
for i, e := range failed {
Expect(after[i].Seq).To(Equal(e.Seq))
Expect(after[i].Role).To(Equal(e.Role))
Expect(after[i].Text).To(Equal(e.Text))
}
var users, tools int
for _, e := range after {
switch e.Role {
case session.RoleUser:
users++
case session.RoleTool:
tools++
}
}
Expect(users).To(Equal(1), "the question is not asked twice")
Expect(tools).To(Equal(2), "the finished tool calls are not made again")
Expect(h.ResultsFor("search")).To(HaveLen(2))
Expect(after[len(after)-1].Role).To(Equal(session.RoleCheckpoint))
Expect(after[len(after)-2].Text).To(Equal("all done"))
// The model got a note to go on, not the question again.
Expect(calls).To(HaveLen(2))
Expect(calls[1]).To(Equal(session.ResumePrompt))
})
It("keeps the reason of the failure with the chat for a reload", func() {
var calls []string
svc, _ := newToolService(failsAtLastStep(&calls))
id := create(svc, alice)
sent(svc, alice, id, "build the report")
idle(svc, alice, id)
es := transcript(svc, alice, id)
idx := slices.IndexFunc(es, func(e session.Entry) bool { return e.Role == session.RoleFailure })
Expect(idx).To(BeNumerically(">", 0), "a failure line ends the failed turn")
f := session.ParseFailure(es[idx])
Expect(f.Code).To(Equal(session.CodeUpstream))
Expect(f.Reason).To(ContainSubstring("HTTP 502"))
// It comes after the last step of the turn, before the checkpoint.
Expect(es[idx-1].Role).To(Equal(session.RoleTool))
Expect(es[idx+1].Role).To(Equal(session.RoleCheckpoint))
})
It("starts one run for two clicks, and none for a chat that did not fail", func() {
var calls []string
svc, _ := newToolService(failsAtLastStep(&calls))
id := create(svc, alice)
none, err := svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
Expect(none.Started).To(BeFalse(), "a fresh chat has nothing to go on with")
sent(svc, alice, id, "build the report")
idle(svc, alice, id)
a, errA := svc.Resume(ctx, alice, id)
b, errB := svc.Resume(ctx, alice, id)
Expect(errA).NotTo(HaveOccurred())
Expect(errB).NotTo(HaveOccurred())
Expect(a.Started).To(BeTrue())
Expect(b.Started).To(BeFalse(), "the second click finds the run of the first")
idle(svc, alice, id)
again, err := svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
Expect(again.Started).To(BeFalse(), "the chat is not at a failed turn any more")
Expect(calls).To(HaveLen(2), "one run for the failure")
})
It("keeps what the person typed during the run and delivers it with the retry", func() {
gate := make(chan struct{})
var calls []string
failed := false
svc, _ := newToolService(func(_ workspace.Actor, text string) []studiotest.ToolStep {
calls = append(calls, text)
if !failed {
failed = true
return []studiotest.ToolStep{{Say: "working"}, {Tool: "search", Args: `{}`}, {Block: gate}, {Err: errors.New("the model failed 3 times")}}
}
return []studiotest.ToolStep{{Say: "done"}}
})
id := create(svc, alice)
sent(svc, alice, id, "build the report")
until("the turn to reach its tool", func() bool { return len(transcript(svc, alice, id)) >= 3 })
// A message sent while the turn runs waits for the next request.
res, err := svc.Send(ctx, alice, id, "use the blue theme")
Expect(err).NotTo(HaveOccurred())
Expect(res.PendingID).NotTo(BeEmpty())
close(gate)
idle(svc, alice, id)
Expect(turn(svc, alice, id).Last).To(Equal(store.TurnFailed))
Expect(pendingTexts(svc, alice, id)).To(Equal([]string{"use the blue theme"}), "the failed run leaves it waiting")
_, err = svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
idle(svc, alice, id)
Expect(pendingTexts(svc, alice, id)).To(BeEmpty())
Expect(calls).To(HaveLen(2))
Expect(calls[1]).To(HavePrefix("use the blue theme"))
Expect(calls[1]).To(HaveSuffix(session.ResumePrompt))
var texts []string
for _, e := range transcript(svc, alice, id) {
if e.Role == session.RoleUser {
texts = append(texts, e.Text)
}
}
Expect(texts).To(Equal([]string{"build the report", "use the blue theme"}))
})
It("keeps the result of a sub-agent that finished before the failure", func() {
failed := false
svc, _ := newToolService(func(_ workspace.Actor, text string) []studiotest.ToolStep {
switch {
case text == "find the answer":
return []studiotest.ToolStep{{Say: "42"}}
case !failed:
failed = true
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"find the answer","wait":true}`}, {Err: errors.New("the model failed 3 times")}}
}
return []studiotest.ToolStep{{Say: "the answer is 42"}}
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
Expect(childrenOf(svc, alice, id)).To(HaveLen(1))
before := shape(transcript(svc, alice, id))
Expect(strings.Join(before, "\n")).To(ContainSubstring("42"))
_, err := svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
idle(svc, alice, id)
Expect(childrenOf(svc, alice, id)).To(HaveLen(1), "the sub-agent is not started again")
after := shape(transcript(svc, alice, id))
Expect(after[:len(before)]).To(Equal(before))
})
It("goes on from the compacted state and compacts nothing again", func() {
compactions := 0
failed := false
svc, h := newToolService(func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "second" && !failed {
failed = true
return []studiotest.ToolStep{{Say: "half"}, {Err: errors.New("the model failed 3 times")}}
}
return []studiotest.ToolStep{{Say: "ok"}}
}, func(d *studio.Deps) { d.Files = newFiles() })
h.Compact = func(string) (int, int, error) { compactions++; return 9000, 3000, nil }
id := create(svc, alice)
sent(svc, alice, id, "first")
idle(svc, alice, id)
_, _, err := svc.Compact(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
sent(svc, alice, id, "second")
idle(svc, alice, id)
Expect(turn(svc, alice, id).Last).To(Equal(store.TurnFailed))
rec, err := svc.Get(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
saved := string(rec.HarnessState)
Expect(saved).To(ContainSubstring(`"compacted":true`))
_, err = svc.Resume(ctx, alice, id)
Expect(err).NotTo(HaveOccurred())
idle(svc, alice, id)
Expect(string(h.Resume(id))).To(Equal(saved), "the model state is the one the failed turn saved, not one rebuilt from the transcript")
Expect(compactions).To(Equal(1))
marks := 0
for _, e := range transcript(svc, alice, id) {
if e.Role == session.RoleCompacted {
marks++
}
}
Expect(marks).To(Equal(1))
})
It("refuses a sub-agent conversation and another user's chat", func() {
svc, _ := newToolService(func(_ workspace.Actor, text string) []studiotest.ToolStep {
if text == "go" {
return []studiotest.ToolStep{{Tool: "spawn_agent", Args: `{"task":"child","wait":true}`}}
}
return nil
})
id := create(svc, alice)
sent(svc, alice, id, "go")
idle(svc, alice, id)
kids := childrenOf(svc, alice, id)
Expect(kids).To(HaveLen(1))
_, err := svc.Resume(ctx, alice, kids[0].ID)
Expect(err).To(MatchError(studio.ErrSubAgentReadOnly))
_, err = svc.Resume(ctx, "bob", id)
Expect(err).To(MatchError(session.ErrNotFound))
})
})
+42 -3
View File
@@ -50,8 +50,19 @@ type Message struct {
// the person; the model gets Text as a user message.
Goal bool
GoalTurn int
// Resume makes the turn go on from where the last one failed. The
// transcript and the model's own state are not touched: the model gets
// ResumePrompt as a message of the host, which the transcript does not
// keep. Any waiting text of Text goes first, as a message of the person.
Resume bool
}
// ResumePrompt is what the model gets when a failed turn goes on. The model
// has the transcript of the failed turn, with the failure noted at its end.
const ResumePrompt = "(System note: your last request stopped on an error before it finished. " +
"Go on from where you stopped. Keep what you already did and do not repeat a tool call that has a result. " +
"If the last step did not finish, do that step again, then finish the request.)"
// withDirective puts the directive of a message before the text the model gets.
func withDirective(directive, text string) string {
if directive == "" {
@@ -661,9 +672,14 @@ func (c *Chat) Send(ctx context.Context, msg Message) error { return c.send(ctx,
// send is Send. logged is true when the message is in the transcript already.
func (c *Chat) send(ctx context.Context, msg Message, logged bool) error {
text := strings.TrimSpace(msg.Text)
if text == "" && msg.Note == "" && len(msg.Files) == 0 && len(msg.Attachments) == 0 && msg.Directive == "" {
if text == "" && msg.Note == "" && len(msg.Files) == 0 && len(msg.Attachments) == 0 && msg.Directive == "" && !msg.Resume {
return fmt.Errorf("%w: empty message", ErrInvalid)
}
if msg.Resume && !logged && text == "" && msg.Note == "" && len(msg.Files) == 0 && len(msg.Attachments) == 0 {
// Nothing of the person's goes in the transcript: the turn goes on
// from it as it is.
logged = true
}
entry := Entry{Role: RoleUser, Text: text, Note: msg.Note, Attachments: msg.Attachments, Loop: msg.Loop, Invoked: msg.Invoked}
if msg.Goal {
entry = Entry{Role: RoleGoal, Text: text, Note: GoalLineContinue, Rev: int64(msg.GoalTurn)}
@@ -686,6 +702,10 @@ func (c *Chat) send(ctx context.Context, msg Message, logged bool) error {
}
}
if c.running {
if msg.Resume {
c.mu.Unlock()
return fmt.Errorf("%w: a turn is running", ErrTurnRunning)
}
if c.stopped {
// Stop was called and the turn has not ended: a message would be
// queued and then dropped.
@@ -755,7 +775,14 @@ func (c *Chat) send(ctx context.Context, msg Message, logged bool) error {
// The transcript keeps the words and the files apart; the model gets
// them together, as it always has.
entry.Attachments = append(slices.Clip(entry.Attachments), saved...)
full := strings.Join(append(carried, withDirective(msg.Directive, c.modelText(entry))), "\n\n")
parts := append(carried, withDirective(msg.Directive, c.modelText(entry)))
if msg.Resume {
if text == "" && msg.Note == "" && len(msg.Attachments) == 0 && len(saved) == 0 {
parts = carried // no words of the person; only the note below
}
parts = append(parts, ResumePrompt)
}
full := strings.Join(parts, "\n\n")
if !logged {
entry.Turn = c.turnNow() + 1
c.appendEntry(ctx, entry)
@@ -903,6 +930,8 @@ func (c *Chat) runTurn(text string, refresh bool, media []Media) int {
c.endMu.Unlock()
note := ""
var turnErr error
// failure is why the turn ended, when an error ended it.
var failure *Failure
if !skip {
var hookRefresh bool
note, hookRefresh, turnErr = c.applyChatTools(ctx, text)
@@ -943,11 +972,17 @@ func (c *Chat) runTurn(text string, refresh bool, media []Media) int {
if err != nil && !(stopped && (errors.Is(err, context.Canceled) || ctx.Err() != nil)) {
c.m.log.Error("session: the harness failed", "chat", c.id, "turn", n, "error", err)
f := Classify(err)
failure = &f
c.emit(Event{Kind: KindError, Turn: n, Text: f.Reason, Code: f.Code, Model: f.Model, Retry: f.RetryAfter, Detail: f.Detail, Fatal: true})
}
}
c.flushReasoning(context.Background(), n)
c.flushText(context.Background(), n)
if failure != nil {
// After the words and the steps of the turn: a reload reads the same
// transcript, with the reason at its end.
c.appendEntry(context.Background(), FailureEntry(n, *failure))
}
ch := c.checkpoint(n, text)
c.appendEntry(context.Background(), Entry{Turn: n, Role: RoleCheckpoint, Rev: ch.Rev, Text: strings.Join(ch.Paths, "\n")})
@@ -983,7 +1018,11 @@ func (c *Chat) checkpoint(n int, text string) ChangeEvent {
if c.vm.sb == nil {
return ChangeEvent{}
}
title := fmt.Sprintf("Turn %d: %s", n, CapText(strings.SplitN(text, "\n", 2)[0], 60))
first := strings.SplitN(text, "\n", 2)[0]
if strings.HasPrefix(text, ResumePrompt) {
first = "go on after an error"
}
title := fmt.Sprintf("Turn %d: %s", n, CapText(first, 60))
ctx, cancel := context.WithTimeout(context.Background(), checkpointLimit)
defer cancel()
sweepTemp(ctx, c.vm.sb)
+27
View File
@@ -2,6 +2,7 @@ package session
import (
"context"
"encoding/json"
"errors"
"regexp"
"strconv"
@@ -56,6 +57,32 @@ type Failure struct {
Detail string
}
// failureNote is the form of a Failure in the Note of a RoleFailure entry.
type failureNote struct {
Code string `json:"code"`
Model string `json:"model,omitempty"`
Retry int `json:"retry,omitempty"`
Detail string `json:"detail,omitempty"`
}
// FailureEntry is the transcript line that keeps why a turn ended: a reload
// of the chat shows the same reason as the live stream did.
func FailureEntry(turn int, f Failure) Entry {
note, _ := json.Marshal(failureNote{Code: f.Code, Model: f.Model, Retry: f.RetryAfter, Detail: f.Detail})
return Entry{Turn: turn, Role: RoleFailure, Text: f.Reason, Note: string(note)}
}
// ParseFailure reads the cause back from a RoleFailure entry. An entry whose
// note cannot be read still gives its text, with the generic code.
func ParseFailure(e Entry) Failure {
f := Failure{Code: CodeAgent, Reason: e.Text}
var n failureNote
if json.Unmarshal([]byte(e.Note), &n) == nil && n.Code != "" {
f.Code, f.Model, f.RetryAfter, f.Detail = n.Code, n.Model, n.Retry, n.Detail
}
return f
}
var (
quotedModelRe = regexp.MustCompile(`model "([^"]{1,128})"`)
retryAfterRe = regexp.MustCompile(`retry after (?:(\d+)m)?(?:(\d+)s)?`)
+5
View File
@@ -261,6 +261,11 @@ const (
// the turn of the goal for a GoalLineContinue line. It is for display:
// the model got only the text of a GoalLineContinue line, with its turn.
RoleGoal = "goal"
// RoleFailure marks where a turn ended on an error. Text is the plain
// reason and Note the cause (see Failure.Encode). It is for display: the
// model never sees it, and a retry that goes on from the transcript keeps
// it.
RoleFailure = "failure"
)
// MaxReasoningBytes is the size limit of a stored reasoning entry, including
+2 -1
View File
@@ -165,7 +165,8 @@ func (h *ToolHarness) Start(_ context.Context, env session.HarnessEnv) (session.
h.resumes[env.Actor.ChatID] = append([]byte(nil), env.Resume...)
h.mu.Unlock()
if h.Compact != nil {
return &compactingToolSession{toolSession: &toolSession{h: h, env: env}}, nil
// A session started from a compacted state is compacted too.
return &compactingToolSession{toolSession: &toolSession{h: h, env: env}, compacted: strings.Contains(string(env.Resume), `"compacted":true`)}, nil
}
return &toolSession{h: h, env: env}, nil
}
+3 -1
View File
@@ -2079,7 +2079,9 @@ When a turn fails, the thread shows one banner: "The reply failed", the reason i
| The provider behind the model returned an error | The provider or a proxy answered with a server error several times in a row. | Retry in a moment, then check the provider. |
| The model took too long to answer / The turn was cancelled | A timeout, or the turn was stopped. | Retry, or use a smaller or faster model. |
A failure that the page learns about only after a reload shows the banner without a reason, and only for a day after the turn. A new turn clears the banner.
The banner stands after the messages, tool calls and results that the turn produced before it failed: nothing of them is removed. **Retry** goes on from where the turn stopped (`POST /api/chats/{id}/resume`). The model keeps its own state, so a tool call that already has a result is not made again, a step that did not finish is done again, the messages you sent while the turn ran stay, and a summary made by a compaction stays as it is. A second click, or a click in another window, starts nothing. To answer your last message again from the start, use **Regenerate** on the message.
The reason is stored with the chat, so a reload shows the same banner as the live page, for a day after the turn. A new turn clears the banner.
## Moving from the old chat
+56 -1
View File
@@ -11070,6 +11070,32 @@ const docTemplate = `{
}
}
},
"/api/chats/{id}/resume": {
"post": {
"description": "Starts a new turn that goes on from where the last one failed. The transcript stays as it is, with the messages, tool calls and results of the failed turn and the reason at its end; the model keeps its own state, so a tool call that has its result is not made again. Messages that wait for the chat go first. When the chat is not at a failed turn, or a turn runs, nothing starts and started is false, so a second click is harmless.",
"tags": [
"chats"
],
"summary": "Go on with the turn that failed.",
"parameters": [
{
"type": "string",
"description": "conversation id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Whether a turn started",
"schema": {
"$ref": "#/definitions/schema.ChatsSendResponse"
}
}
}
}
},
"/api/chats/{id}/send-now": {
"post": {
"description": "After a stop or a failed turn the waiting messages stay put. This starts a turn from them.",
@@ -23486,6 +23512,27 @@ const docTemplate = `{
}
}
},
"schema.ChatsFailure": {
"type": "object",
"properties": {
"code": {
"description": "Code names the cause, such as model_not_found, upstream, timeout or agent.",
"type": "string"
},
"detail": {
"description": "Detail is the error text, cleaned, for the technical view.",
"type": "string"
},
"model": {
"description": "Model is the model the cause is about, when it is known.",
"type": "string"
},
"retry_after": {
"description": "RetryAfter is the seconds until a model in cooldown loads again.",
"type": "integer"
}
}
},
"schema.ChatsFile": {
"type": "object",
"properties": {
@@ -24531,6 +24578,14 @@ const docTemplate = `{
"description": "DurMs is how long the model thought, for reasoning lines.",
"type": "integer"
},
"failure": {
"description": "Failure is set on a failure line: why the turn ended (Text is the reason\nin English).",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsFailure"
}
]
},
"goal_event": {
"description": "GoalEvent is set on a goal line: set, replaced, continue (a message the\nserver sent by itself to go on; Text is that message and GoalTurn its\nautomatic turn), paused (Text is the reason), resumed, done (Text is the\nsummary), blocked (Text is the reason) or cleared.",
"type": "string"
@@ -24559,7 +24614,7 @@ const docTemplate = `{
"type": "integer"
},
"role": {
"description": "user, assistant, tool, checkpoint, reasoning, compacted, cleared or goal",
"description": "user, assistant, tool, checkpoint, reasoning, compacted, cleared, goal or failure",
"type": "string"
},
"seq": {
+56 -1
View File
@@ -11067,6 +11067,32 @@
}
}
},
"/api/chats/{id}/resume": {
"post": {
"description": "Starts a new turn that goes on from where the last one failed. The transcript stays as it is, with the messages, tool calls and results of the failed turn and the reason at its end; the model keeps its own state, so a tool call that has its result is not made again. Messages that wait for the chat go first. When the chat is not at a failed turn, or a turn runs, nothing starts and started is false, so a second click is harmless.",
"tags": [
"chats"
],
"summary": "Go on with the turn that failed.",
"parameters": [
{
"type": "string",
"description": "conversation id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "Whether a turn started",
"schema": {
"$ref": "#/definitions/schema.ChatsSendResponse"
}
}
}
}
},
"/api/chats/{id}/send-now": {
"post": {
"description": "After a stop or a failed turn the waiting messages stay put. This starts a turn from them.",
@@ -23483,6 +23509,27 @@
}
}
},
"schema.ChatsFailure": {
"type": "object",
"properties": {
"code": {
"description": "Code names the cause, such as model_not_found, upstream, timeout or agent.",
"type": "string"
},
"detail": {
"description": "Detail is the error text, cleaned, for the technical view.",
"type": "string"
},
"model": {
"description": "Model is the model the cause is about, when it is known.",
"type": "string"
},
"retry_after": {
"description": "RetryAfter is the seconds until a model in cooldown loads again.",
"type": "integer"
}
}
},
"schema.ChatsFile": {
"type": "object",
"properties": {
@@ -24528,6 +24575,14 @@
"description": "DurMs is how long the model thought, for reasoning lines.",
"type": "integer"
},
"failure": {
"description": "Failure is set on a failure line: why the turn ended (Text is the reason\nin English).",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsFailure"
}
]
},
"goal_event": {
"description": "GoalEvent is set on a goal line: set, replaced, continue (a message the\nserver sent by itself to go on; Text is that message and GoalTurn its\nautomatic turn), paused (Text is the reason), resumed, done (Text is the\nsummary), blocked (Text is the reason) or cleared.",
"type": "string"
@@ -24556,7 +24611,7 @@
"type": "integer"
},
"role": {
"description": "user, assistant, tool, checkpoint, reasoning, compacted, cleared or goal",
"description": "user, assistant, tool, checkpoint, reasoning, compacted, cleared, goal or failure",
"type": "string"
},
"seq": {
+46 -2
View File
@@ -5992,6 +5992,22 @@ definitions:
text:
type: string
type: object
schema.ChatsFailure:
properties:
code:
description: Code names the cause, such as model_not_found, upstream, timeout
or agent.
type: string
detail:
description: Detail is the error text, cleaned, for the technical view.
type: string
model:
description: Model is the model the cause is about, when it is known.
type: string
retry_after:
description: RetryAfter is the seconds until a model in cooldown loads again.
type: integer
type: object
schema.ChatsFile:
properties:
conflict_of:
@@ -6755,6 +6771,12 @@ definitions:
dur_ms:
description: DurMs is how long the model thought, for reasoning lines.
type: integer
failure:
allOf:
- $ref: '#/definitions/schema.ChatsFailure'
description: |-
Failure is set on a failure line: why the turn ended (Text is the reason
in English).
goal_event:
description: |-
GoalEvent is set on a goal line: set, replaced, continue (a message the
@@ -6779,8 +6801,8 @@ definitions:
{rev} undoes it.
type: integer
role:
description: user, assistant, tool, checkpoint, reasoning, compacted, cleared
or goal
description: user, assistant, tool, checkpoint, reasoning, compacted, cleared,
goal or failure
type: string
seq:
type: integer
@@ -16417,6 +16439,28 @@ paths:
summary: Regenerate the last answer.
tags:
- chats
/api/chats/{id}/resume:
post:
description: Starts a new turn that goes on from where the last one failed.
The transcript stays as it is, with the messages, tool calls and results of
the failed turn and the reason at its end; the model keeps its own state,
so a tool call that has its result is not made again. Messages that wait for
the chat go first. When the chat is not at a failed turn, or a turn runs,
nothing starts and started is false, so a second click is harmless.
parameters:
- description: conversation id
in: path
name: id
required: true
type: string
responses:
"200":
description: Whether a turn started
schema:
$ref: '#/definitions/schema.ChatsSendResponse'
summary: Go on with the turn that failed.
tags:
- chats
/api/chats/{id}/send-now:
post:
description: After a stop or a failed turn the waiting messages stay put. This