Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
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"`
|
|
// Warning rides alongside Done: the turn worked, but something adjacent to it
|
|
// didn't, and saying nothing would be the quieter lie.
|
|
Warning string `json:"warning,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
|
|
}
|
|
|
|
send := openEventStream(c)
|
|
|
|
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
|
|
}
|
|
|
|
// The turn itself succeeded — the garden really did change — so Done goes out
|
|
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
|
|
// followed by a conversation that has forgotten the exchange after a reload is
|
|
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
|
|
//
|
|
// Detached from the request context, because the commonest reason this fails
|
|
// is the client having gone away — and the exchange is worth keeping either
|
|
// way, since the change set it produced certainly is.
|
|
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(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, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
|
|
return
|
|
}
|
|
send(chatEvent{Done: turn})
|
|
}
|
|
|
|
// openEventStream puts the response into SSE mode and returns a sender.
|
|
//
|
|
// Headers go out before the first write and the stream is flushed immediately,
|
|
// so a proxy holding the response until it looks complete can't reintroduce
|
|
// exactly the silence streaming exists to remove.
|
|
func openEventStream(c *gin.Context) func(chatEvent) {
|
|
c.Header("Content-Type", "text/event-stream")
|
|
c.Header("Cache-Control", "no-cache")
|
|
c.Header("X-Accel-Buffering", "no")
|
|
c.Writer.Flush()
|
|
|
|
return 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()
|
|
}
|
|
}
|
|
|
|
// 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."
|
|
}
|
|
}
|