feat: respuestas como reply de Matrix + presencia online/offline

Añade soporte para que las respuestas de los bots sean replies nativos
de Matrix (m.in_reply_to) en lugar de mensajes sueltos. Los clientes
Matrix mostrarán el mensaje original citado.

Cambios:
- EventID en MessageContext para capturar el ID del evento entrante
- InReplyTo en ReplyAction para indicar a qué evento responder
- SendReplyMarkdown en el cliente Matrix (shell/matrix/client.go)
- Runner usa SendReplyMarkdown cuando InReplyTo está presente
- runtime.go pasa InReplyTo en todas las respuestas LLM y comandos
- SetPresence online al arrancar, offline al apagar (graceful)

No se tocan: herramientas, TUI, configuración de agentes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 15:46:07 +00:00
parent 29decb3321
commit 76ff9394d0
5 changed files with 51 additions and 10 deletions
+18 -6
View File
@@ -285,6 +285,18 @@ func (a *Agent) Run(ctx context.Context) error {
"tools", a.toolReg.Names(), "tools", a.toolReg.Names(),
) )
// Set presence to online
if err := a.matrix.SetPresence(ctx, event.PresenceOnline); err != nil {
a.logger.Warn("failed to set presence online", "err", err)
}
defer func() {
// Use background context since ctx is already cancelled at shutdown
offlineCtx := context.Background()
if err := a.matrix.SetPresence(offlineCtx, event.PresenceOffline); err != nil {
a.logger.Warn("failed to set presence offline", "err", err)
}
}()
// Start bus listener if connected to the orchestration bus // Start bus listener if connected to the orchestration bus
if a.agentBus != nil { if a.agentBus != nil {
ch := a.agentBus.Subscribe(bus.AgentID(a.cfg.Agent.ID)) ch := a.agentBus.Subscribe(bus.AgentID(a.cfg.Agent.ID))
@@ -453,14 +465,14 @@ func (a *Agent) handleEvent(ctx context.Context, msgCtx decision.MessageContext,
if handler, ok := a.commands[cmdName]; ok { if handler, ok := a.commands[cmdName]; ok {
a.logger.Info("command_executed", "command", cmdName) a.logger.Info("command_executed", "command", cmdName)
reply := handler(ctx, msgCtx) reply := handler(ctx, msgCtx)
_ = a.matrix.SendMarkdown(ctx, roomID, reply) _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID, reply)
return return
} }
// Unknown command — never falls through to rules or LLM // Unknown command — never falls through to rules or LLM
a.logger.Info("command_unknown", "command", msgCtx.Command) a.logger.Info("command_unknown", "command", msgCtx.Command)
_ = a.matrix.SendMarkdown(ctx, roomID, _ = a.matrix.SendReplyMarkdown(ctx, roomID, msgCtx.EventID,
fmt.Sprintf("Comando desconocido: !%s. Usa !help para ver comandos disponibles.", msgCtx.Command)) fmt.Sprintf("Comando desconocido: `!%s`. Usa `!help` para ver comandos disponibles.", msgCtx.Command))
return return
} }
@@ -502,7 +514,7 @@ func (a *Agent) executeActions(ctx context.Context, roomID string, msgCtx decisi
a.logger.Warn("LLM action requested but no LLM configured") a.logger.Warn("LLM action requested but no LLM configured")
expanded = append(expanded, decision.Action{ expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply, Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: "Este bot no tiene LLM configurado."}, Reply: &decision.ReplyAction{Content: "Este bot no tiene LLM configurado.", InReplyTo: msgCtx.EventID},
}) })
continue continue
} }
@@ -518,12 +530,12 @@ func (a *Agent) executeActions(ctx context.Context, roomID string, msgCtx decisi
a.logger.Error("llm error", "err", err) a.logger.Error("llm error", "err", err)
expanded = append(expanded, decision.Action{ expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply, Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error."}, Reply: &decision.ReplyAction{Content: "Sorry, I encountered an error.", InReplyTo: msgCtx.EventID},
}) })
} else { } else {
expanded = append(expanded, decision.Action{ expanded = append(expanded, decision.Action{
Kind: decision.ActionKindReply, Kind: decision.ActionKindReply,
Reply: &decision.ReplyAction{Content: reply}, Reply: &decision.ReplyAction{Content: reply, InReplyTo: msgCtx.EventID},
}) })
// Memory: append assistant reply after LLM call // Memory: append assistant reply after LLM call
+5 -3
View File
@@ -9,6 +9,7 @@ type MessageContext struct {
SenderID string SenderID string
SenderName string SenderName string
RoomID string RoomID string
EventID string // Matrix event ID of the incoming message
Content string Content string
Command string // parsed command name, e.g. "deploy" Command string // parsed command name, e.g. "deploy"
Args []string // parsed arguments Args []string // parsed arguments
@@ -47,9 +48,10 @@ type Action struct {
} }
type ReplyAction struct { type ReplyAction struct {
Content string Content string
ThreadID string // empty = new thread ThreadID string // empty = new thread
Reaction string // optional Matrix reaction InReplyTo string // Matrix event ID to reply to (m.in_reply_to)
Reaction string // optional Matrix reaction
} }
type LLMAction struct { type LLMAction struct {
+7 -1
View File
@@ -23,6 +23,7 @@ type Result struct {
type MatrixSender interface { type MatrixSender interface {
SendText(ctx context.Context, roomID, text string) error SendText(ctx context.Context, roomID, text string) error
SendMarkdown(ctx context.Context, roomID, markdown string) error SendMarkdown(ctx context.Context, roomID, markdown string) error
SendReplyMarkdown(ctx context.Context, roomID, inReplyTo, markdown string) error
SendTyping(ctx context.Context, roomID string, typing bool) error SendTyping(ctx context.Context, roomID string, typing bool) error
} }
@@ -66,7 +67,12 @@ func (r *Runner) executeOne(ctx context.Context, roomID string, a decision.Actio
if a.Reply.ThreadID != "" { if a.Reply.ThreadID != "" {
target = a.Reply.ThreadID target = a.Reply.ThreadID
} }
err := r.matrix.SendMarkdown(ctx, target, a.Reply.Content) var err error
if a.Reply.InReplyTo != "" {
err = r.matrix.SendReplyMarkdown(ctx, target, a.Reply.InReplyTo, a.Reply.Content)
} else {
err = r.matrix.SendMarkdown(ctx, target, a.Reply.Content)
}
return Result{Action: a, Output: a.Reply.Content, Err: err} return Result{Action: a, Output: a.Reply.Content, Err: err}
case decision.ActionKindSSH: case decision.ActionKindSSH:
+20
View File
@@ -305,6 +305,21 @@ func mdToHTML(md string) string {
return buf.String() return buf.String()
} }
// SendReplyMarkdown sends a formatted message as a reply to a specific event.
// It sets m.in_reply_to so Matrix clients show the original message as a quote.
func (c *Client) SendReplyMarkdown(ctx context.Context, roomID, inReplyTo, markdown string) error {
html := mdToHTML(markdown)
content := event.MessageEventContent{
MsgType: event.MsgText,
Body: markdown,
Format: event.FormatHTML,
FormattedBody: html,
RelatesTo: (&event.RelatesTo{}).SetReplyTo(id.EventID(inReplyTo)),
}
_, err := c.raw.SendMessageEvent(ctx, id.RoomID(roomID), event.EventMessage, content)
return err
}
// SendReaction sends a reaction to an event. // SendReaction sends a reaction to an event.
func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error { func (c *Client) SendReaction(ctx context.Context, roomID, eventID, reaction string) error {
_, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction) _, err := c.raw.SendReaction(ctx, id.RoomID(roomID), id.EventID(eventID), reaction)
@@ -448,6 +463,11 @@ func truncateKey(key string) string {
return key return key
} }
// SetPresence sets the bot's presence status (online, unavailable, offline).
func (c *Client) SetPresence(ctx context.Context, status event.Presence) error {
return c.raw.SetPresence(ctx, status)
}
// Raw returns the underlying mautrix.Client for advanced use. // Raw returns the underlying mautrix.Client for advanced use.
func (c *Client) Raw() *mautrix.Client { func (c *Client) Raw() *mautrix.Client {
return c.raw return c.raw
+1
View File
@@ -134,6 +134,7 @@ func (l *Listener) Run(ctx context.Context) error {
isDM, isDM,
opts, opts,
) )
msgCtx.EventID = evt.ID.String()
l.logger.Debug("message parsed", l.logger.Debug("message parsed",
"sender", msgCtx.SenderID, "sender", msgCtx.SenderID,