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 JSON block using this shape: {"name":"","description":"","instructions":"","model":"","skill_ids":[],"permission_scope":"private","member_ids":[]} 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:") }