Files
multica/server/internal/handler/agent_builder.go
Bohan Jiang 168620fc15 MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.

- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.

Closes #5773
2026-07-23 10:56:53 +08:00

316 lines
12 KiB
Go

package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
const agentBuilderInstructions = `You are Multica Agent Builder. Help the user design one practical AI agent through a short conversation.
Your job is to propose and refine configuration, never to create resources yourself. Ask only questions that materially change behavior. Prefer making a reasonable draft immediately, then ask at most two focused questions per turn.
Every response MUST end with exactly one <agent_draft> JSON block using this shape:
<agent_draft>{"name":"","description":"","instructions":"","model":"","skill_ids":[],"permission_scope":"private","member_ids":[]}</agent_draft>
Rules:
- The JSON must be valid, compact JSON on one physical line. Do not wrap it in Markdown fences.
- Escape every line break inside instructions as \n. Never place a literal newline inside a JSON string.
- Preserve good existing draft fields supplied in the user's message unless the user asks to change them.
- name is concise and suitable for a workspace list.
- description is one sentence, at most 200 characters.
- instructions are a complete Markdown system prompt describing role, workflow, output, and constraints.
- model must be empty, preserve current_draft.model, or exactly match an id explicitly listed in AVAILABLE RUNTIME MODELS. Never use a model label as the id.
- When AVAILABLE RUNTIME MODELS is null or empty, preserve current_draft.model and never invent a model id.
- skill_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE SKILLS.
- permission_scope must be private, workspace, or members. Default to private unless the user explicitly requests sharing.
- member_ids may only contain IDs explicitly listed in AVAILABLE WORKSPACE MEMBERS, and only when permission_scope is members.
- Never request, expose, or place secrets, tokens, passwords, or environment-variable values in the draft.
- Do not claim that the agent has been created. The user must review and confirm the draft in the UI.`
type CreateAgentBuilderSessionRequest struct {
RuntimeID string `json:"runtime_id"`
Model string `json:"model,omitempty"`
}
type CreateAgentBuilderSessionResponse struct {
SessionID string `json:"session_id"`
BuilderAgentID string `json:"builder_agent_id"`
RuntimeID string `json:"runtime_id"`
}
// CreateAgentBuilderSession starts a private configuration conversation on an
// existing runtime. A hidden system agent is the execution carrier because the
// chat/task pipeline is intentionally agent-backed; it never appears in normal
// agent lists and cannot be selected as an assignee.
func (h *Handler) CreateAgentBuilderSession(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req CreateAgentBuilderSessionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
runtimeID := strings.TrimSpace(req.RuntimeID)
if runtimeID == "" {
writeError(w, http.StatusBadRequest, "runtime_id is required")
return
}
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
runtime, ok := h.resolveBuilderRuntime(w, r, workspaceID, workspaceUUID, runtimeID, "start")
if !ok {
return
}
flowID := uuid.NewString()
ownerUUID := parseUUID(userID)
model := strings.TrimSpace(req.Model)
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to start agent builder session")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
// FOR KEY SHARE on the workspace row before creating the builder's chat_session
// — the creator half of the #5219 delete/create protocol, so a session cannot
// be created into a workspace mid-delete (see LockWorkspaceForChatSessionCreate).
if _, err := qtx.LockWorkspaceForChatSessionCreate(r.Context(), workspaceUUID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "workspace not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to lock workspace")
return
}
builder, err := qtx.CreateAgentBuilder(r.Context(), db.CreateAgentBuilderParams{
WorkspaceID: workspaceUUID,
Name: fmt.Sprintf(".multica-agent-builder-%s", flowID),
RuntimeMode: runtime.RuntimeMode,
RuntimeID: runtime.ID,
OwnerID: ownerUUID,
Instructions: agentBuilderInstructions,
Model: pgtype.Text{String: model, Valid: model != ""},
SystemKey: pgtype.Text{
String: fmt.Sprintf("agent_builder:%s", flowID),
Valid: true,
},
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to prepare agent builder")
return
}
session, err := qtx.CreateChatSession(r.Context(), db.CreateChatSessionParams{
WorkspaceID: workspaceUUID,
AgentID: builder.ID,
CreatorID: ownerUUID,
Title: "Create an agent",
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create agent builder session")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit agent builder session")
return
}
writeJSON(w, http.StatusCreated, CreateAgentBuilderSessionResponse{
SessionID: uuidToString(session.ID),
BuilderAgentID: uuidToString(builder.ID),
RuntimeID: runtimeID,
})
}
// resolveBuilderRuntime loads a runtime the caller is allowed to execute a
// builder conversation on. Shared by session create and runtime switch so both
// enforce the same three gates in the same order: it exists in this workspace,
// this member may use it (private runtimes stay owner/admin-only), and it is
// online. verb names the attempted action in the offline error so the two call
// sites read naturally.
func (h *Handler) resolveBuilderRuntime(w http.ResponseWriter, r *http.Request, workspaceID string, workspaceUUID pgtype.UUID, runtimeID, verb string) (db.AgentRuntime, bool) {
runtimeUUID, ok := parseUUIDOrBadRequest(w, runtimeID, "runtime_id")
if !ok {
return db.AgentRuntime{}, false
}
runtime, err := h.Queries.GetAgentRuntimeForWorkspace(r.Context(), db.GetAgentRuntimeForWorkspaceParams{
ID: runtimeUUID,
WorkspaceID: workspaceUUID,
})
if err != nil {
writeError(w, http.StatusBadRequest, "invalid runtime_id")
return db.AgentRuntime{}, false
}
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return db.AgentRuntime{}, false
}
if !canUseRuntimeForAgent(member, runtime) {
writeError(w, http.StatusForbidden, "this runtime is private; only its owner or a workspace admin can use it")
return db.AgentRuntime{}, false
}
if runtime.Status != "online" {
writeError(w, http.StatusConflict, fmt.Sprintf("runtime must be online to %s an agent builder session", verb))
return db.AgentRuntime{}, false
}
return runtime, true
}
type SwitchAgentBuilderRuntimeRequest struct {
RuntimeID string `json:"runtime_id"`
}
type SwitchAgentBuilderRuntimeResponse struct {
RuntimeID string `json:"runtime_id"`
}
// SwitchAgentBuilderRuntime re-points a live builder conversation at another
// runtime. The live-draft runtime picker used to mutate React state only, so the
// UI could show runtime B while every subsequent message still enqueued against
// the carrier agent frozen to runtime A at session create time (MUL-5163).
//
// The rebind runs under LockChatSessionForRuntimeBind, the same row lock
// SendDirectChatMessage takes, so "no reply is in flight" and "the carrier now
// points at B" are decided in one serialised step. Without that lock a send that
// had already read runtime A could still land its task after this handler
// returned success — reproducing the exact inconsistency this endpoint exists to
// remove.
//
// chat_session.runtime_id is deliberately left pointing at the old runtime: the
// daemon only resumes a stored provider session when that pointer matches the
// claiming task's runtime, so leaving it stale is what makes B start a fresh
// provider session instead of resuming A's. Multica-side chat history and the
// draft are untouched.
func (h *Handler) SwitchAgentBuilderRuntime(w http.ResponseWriter, r *http.Request) {
workspaceID := h.resolveWorkspaceID(r)
userID, ok := requireUserID(w, r)
if !ok {
return
}
var req SwitchAgentBuilderRuntimeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
runtimeID := strings.TrimSpace(req.RuntimeID)
if runtimeID == "" {
writeError(w, http.StatusBadRequest, "runtime_id is required")
return
}
// Creator-only, like every other write on a chat session.
session, ok := h.loadChatSessionForUser(w, r, userID, workspaceID, chi.URLParam(r, "sessionId"))
if !ok {
return
}
if session.Status != "active" {
writeError(w, http.StatusBadRequest, "chat session is archived")
return
}
// Only builder carriers may be rebound. A user-authored agent changes runtime
// through the agent update path, which has its own permission model — this
// endpoint must not become a second, weaker way in.
agent, err := h.Queries.GetAgent(r.Context(), session.AgentID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load chat agent")
return
}
if !isAgentBuilderCarrier(agent) {
writeError(w, http.StatusNotFound, "agent builder session not found")
return
}
workspaceUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
if !ok {
return
}
runtime, ok := h.resolveBuilderRuntime(w, r, workspaceID, workspaceUUID, runtimeID, "switch")
if !ok {
return
}
tx, err := h.TxStarter.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to switch agent builder runtime")
return
}
defer tx.Rollback(r.Context())
qtx := h.Queries.WithTx(tx)
if _, err := qtx.LockChatSessionForRuntimeBind(r.Context(), session.ID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "chat session not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to lock chat session")
return
}
// Checked under the lock, so a send cannot slip in behind it. A task that is
// still queued on an offline runtime also counts as pending — the client is
// expected to stop it first, which restores the message to the composer.
if _, err := qtx.GetPendingChatTask(r.Context(), session.ID); err == nil {
writeError(w, http.StatusConflict, "stop the current reply before switching runtime")
return
} else if !errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusInternalServerError, "failed to check pending builder task")
return
}
// Model ids are per-runtime, so the carrier's model is cleared rather than
// carried over; the new runtime resolves its own default.
updated, err := qtx.RebindAgentBuilderRuntime(r.Context(), db.RebindAgentBuilderRuntimeParams{
ID: agent.ID,
RuntimeID: runtime.ID,
RuntimeMode: runtime.RuntimeMode,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "agent builder session not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to switch agent builder runtime")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "failed to commit agent builder runtime switch")
return
}
writeJSON(w, http.StatusOK, SwitchAgentBuilderRuntimeResponse{
RuntimeID: uuidToString(updated.RuntimeID),
})
}
// isAgentBuilderCarrier reports whether an agent is a hidden builder execution
// carrier. Mirrors the kind/system_key guard the builder SQL statements carry, so
// the handler rejects a non-builder session before reaching the database rather
// than relying on an UPDATE matching zero rows.
func isAgentBuilderCarrier(agent db.Agent) bool {
return agent.Kind == "system" &&
agent.SystemKey.Valid &&
strings.HasPrefix(agent.SystemKey.String, "agent_builder:")
}