From 11673dabf1949510da17b3eeab155d40e4a1e985 Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 20 Mar 2026 18:46:27 +0700 Subject: [PATCH] fix(agent): prevent trailing assistant messages for proxy provider compat Reorder team task reminders to inject before the user message instead of after, ensuring the user's message is always the last in the messages array. Proxy providers (LiteLLM, OpenRouter) reject requests ending with an assistant-role message (unsupported prefill). Also add a safety net in the OpenAI provider to strip any trailing assistant message that might slip through in the future. --- internal/agent/loop.go | 12 +++++++++++- internal/providers/openai.go | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 436216bc..a37499c2 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -396,6 +396,8 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) // 2g. Cross-session task reminder: notify team leads about pending and in-progress tasks. // Stale recovery (expired lock → pending) is handled by the background TaskTicker. + // Reminders are injected BEFORE the user message so the user's actual message is always + // the last message — prevents trailing assistant messages that proxy providers reject. if l.teamStore != nil && l.agentUUID != uuid.Nil { if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil && team.LeadAgentID == l.agentUUID { if tasks, err := l.teamStore.ListTasks(ctx, team.ID, "newest", "active", req.UserID, "", "", 0, 0); err == nil { @@ -434,16 +436,20 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } if len(parts) > 0 { reminder := "[System] " + strings.Join(parts, "\n\n") + // Pop user message, inject reminder, push user message back + userMsg := messages[len(messages)-1] + messages = messages[:len(messages)-1] messages = append(messages, providers.Message{Role: "user", Content: reminder}, providers.Message{Role: "assistant", Content: "I see the task status. Let me handle accordingly."}, + userMsg, ) } } } } - // 2g. Member task reminder: inject task context for members working on dispatched tasks. + // 2h. Member task reminder: inject task context for members working on dispatched tasks. // Caches task subject/number for mid-loop progress nudge (avoids extra DB query). var memberTaskSubject string var memberTaskNumber int @@ -457,9 +463,13 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) "Stay focused on this task. Your final response becomes the task result — make it clear and complete. "+ "For long tasks, report progress: team_tasks(action=\"progress\", percent=50, text=\"status\").", task.TaskNumber, task.Subject) + // Pop user message, inject reminder, push user message back + userMsg := messages[len(messages)-1] + messages = messages[:len(messages)-1] messages = append(messages, providers.Message{Role: "user", Content: reminder}, providers.Message{Role: "assistant", Content: "Understood. I'll focus on this task and report progress."}, + userMsg, ) } } diff --git a/internal/providers/openai.go b/internal/providers/openai.go index 49692143..9a1c014a 100644 --- a/internal/providers/openai.go +++ b/internal/providers/openai.go @@ -340,6 +340,17 @@ func (p *OpenAIProvider) buildRequestBody(model string, req ChatRequest, stream msgs = append(msgs, msg) } + // Safety net: strip trailing assistant message to prevent HTTP 400 from + // proxy providers (LiteLLM, OpenRouter) that don't support assistant prefill. + // This should rarely trigger — the agent loop ensures user message is last. + if len(msgs) > 0 { + if role, _ := msgs[len(msgs)-1]["role"].(string); role == "assistant" { + slog.Warn("openai: stripped trailing assistant message (unsupported prefill)", + "provider", p.name, "model", model) + msgs = msgs[:len(msgs)-1] + } + } + body := map[string]any{ "model": model, "messages": msgs,