package api import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent" "gitea.stevedudenhoeffer.com/steve/majordomo/llm" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agent" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" ) // The garden assistant's chat surface (#56). // // Streaming, because a turn that clears a bed and replants it makes a dozen tool // calls over tens of seconds. Without streaming that is a long silence followed // by everything at once, which reads as a hang — and the whole design rests on // watching the canvas change as it happens. // chatRequest is the body of POST /agent/chat. type chatRequest struct { GardenID int64 `json:"gardenId" binding:"required"` Message string `json:"message" binding:"required"` } // chatEvent is one server-sent event. Exactly one field is set. type chatEvent struct { // Step reports a completed model round trip: which tools it called. Step *stepEvent `json:"step,omitempty"` // Done carries the finished turn. Done *agent.Turn `json:"done,omitempty"` // Error is a turn that failed, in words meant for a person. Error string `json:"error,omitempty"` } type stepEvent struct { Index int `json:"index"` Tools []string `json:"tools"` } func (h *handlers) agentChat(c *gin.Context) { var req chatRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required") return } actor := mustActor(c) history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID) if err != nil { writeServiceError(c, err) return } // Headers before the first write, and a flush straight away: a proxy that // buffers the response would reintroduce exactly the silence streaming is // here to remove. c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("X-Accel-Buffering", "no") c.Writer.Flush() send := func(ev chatEvent) { b, err := json.Marshal(ev) if err != nil { slog.Error("api: encode chat event", "error", err) return } _, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b) c.Writer.Flush() } turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, replayHistory(history), func(s mdagent.Step) { send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}}) }) if err != nil { // The stream is already open, so an error is an event rather than a // status code — the client has committed to reading a stream by now. send(chatEvent{Error: chatErrorMessage(err)}) return } // Record before announcing: if persistence fails, the user should see that // their turn wasn't saved rather than a clean "done" followed by a thread // that has forgotten it. if _, err := h.svc.RecordAgentExchange(c.Request.Context(), actor.ID, req.GardenID, req.Message, turn.Reply, turn.ChangeSetID); err != nil { slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID) } send(chatEvent{Done: turn}) } // getAgentHistory returns the actor's thread for a garden. func (h *handlers) getAgentHistory(c *gin.Context) { gardenID, ok := parseIDParam(c, "id") if !ok { return } msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"messages": msgs}) } // deleteAgentHistory is the "start over" escape hatch. func (h *handlers) deleteAgentHistory(c *gin.Context) { gardenID, ok := parseIDParam(c, "id") if !ok { return } if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil { writeServiceError(c, err) return } c.Status(http.StatusNoContent) } // replayHistory turns stored text into model messages. // // Only the text is replayed — no stored tool calls. Continuity needs what was // said and what came back; replaying a tool call would be replaying a decision // made against a garden that has since moved on. func replayHistory(msgs []domain.AgentMessage) []llm.Message { out := make([]llm.Message, 0, len(msgs)) for _, m := range msgs { if m.Role == domain.AgentRoleUser { out = append(out, llm.UserText(m.Body)) } else { out = append(out, llm.AssistantText(m.Body)) } } return out } func toolNames(s mdagent.Step) []string { names := make([]string, 0, len(s.Results)) for _, r := range s.Results { names = append(names, r.Name) } return names } // chatErrorMessage turns a failure into something worth reading. Permission // errors in particular get named: "the agent broke" and "you can only view this // garden" want very different reactions. func chatErrorMessage(err error) string { switch { case errors.Is(err, domain.ErrForbidden): return "You can only view this garden, so I can't change anything in it." case errors.Is(err, domain.ErrNotFound): return "I can't find that garden." case errors.Is(err, domain.ErrInvalidInput): return "I didn't get a message to work from." case errors.Is(err, io.EOF), errors.Is(err, context.Canceled): return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History." case errors.Is(err, context.DeadlineExceeded): return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History." default: slog.Error("api: agent run failed", "error", err) return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History." } }