Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #70.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user