mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Storage, flag and request path of the approval gate for asset targets. The
gate itself is not wired into the tool hook yet (steps 1.5 to 1.7): with this
change nothing is extracted, resolved or blocked, whatever the flag says.
Schema and store (1.2)
- tasks.target_gate, conversations.target_gate (default false).
- intercept_pending.target_key and a partial unique index: one open request
per scope (task or conversation) and host:port.
- target_pins (no foreign keys: a pin outlives its task, its conversation and
its request row) and target_gate_exemption_log. All DDL is idempotent; no
CHECK constraints; the cascade on intercept_pending.conversation_id stays.
- db/target_pins.go: pin lookup with port precedence, list, revoke,
find-or-create of a request, and ResolveTargetRequest, which settles the
row, supersedes the old pin and writes the new version in one transaction.
- Deleting a task or an archive stub revokes its pins and settles its open
target requests in the delete transaction. DeleteConversation and
DeleteConversations revoke the pins of the removed conversations in the
same transaction as the delete.
Opt-in flag (1.3)
- Per task through the create and metadata-patch paths, per conversation
through create and patch; exposed in the task DTO. A retest conversation
takes the flag of the finding's task, never its pins. A spawned task starts
with the gate off.
- The task flag is atomic and read on every call through TargetScope.Enabled.
TargetScope now carries the Interceptor that owns the gate.
Request, wait, decide (1.4)
- pendingManager keeps a list of waiters per request; resolve wakes all.
- RequestTargets: find-or-create per target, one card per request, wait on
channel, poll, context and wait bound. A plain request is never settled by
a call that leaves. A request merged with an ask is bound to its creating
call and a timeout never allows it.
- Decide records the approver (subject of the session token) and the remote
address for every human decision, classic asks included. On a target
request "allowed" pins the displayed addresses; that is the only writer of
pins.
- A request raised while the global egress proxy is set says the approval is
name-only and its pin is stored with name_only_effective.
- Routes: GET/POST .../target-pins and .../target-pins/{pid}/revoke under
/api/tasks/{id} and /api/conversations/{id}; decision_source=target filter.
- ValidateExemptionHost: an exemption written as an IP literal may be private
unicast, never loopback, link-local or metadata, unspecified or multicast.
840 lines
28 KiB
Go
840 lines
28 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/Autumn-27/artex/agent"
|
|
"github.com/Autumn-27/artex/db"
|
|
"github.com/Autumn-27/artex/intercept"
|
|
"github.com/Autumn-27/artex/sidequestion"
|
|
)
|
|
|
|
const (
|
|
maxConversationRequestBytes = 64 << 10
|
|
maxConversationAgentKeyRunes = 120
|
|
maxConversationTitleRunes = 200
|
|
)
|
|
|
|
func decodeConversationRequest(w http.ResponseWriter, r *http.Request, value any) bool {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxConversationRequestBytes)
|
|
if err := decode(r, value); err != nil {
|
|
var tooLarge *http.MaxBytesError
|
|
if errors.As(err, &tooLarge) {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "Request body too large")
|
|
} else {
|
|
writeErr(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---------- conversations (chat page) ----------
|
|
//
|
|
// A conversation is a ChatGPT-style thread bound to an agent key, independent of
|
|
// the pentest task graph. Turns run on ChatAgent; steps persist to
|
|
// conversation_activities and the browser POLLS ?since=cursor for live updates
|
|
// (no per-conversation SSE broadcaster needed).
|
|
|
|
type conversationListItem struct {
|
|
*db.Conversation
|
|
Running bool `json:"running"`
|
|
}
|
|
|
|
func (s *Server) pgListConversations(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
cs, err := pg.ListConversations()
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
items := make([]conversationListItem, 0, len(cs))
|
|
s.chatMu.Lock()
|
|
for _, c := range cs {
|
|
items = append(items, conversationListItem{Conversation: c, Running: s.chatBusy[s.convBusyKey(c.ID)]})
|
|
}
|
|
s.chatMu.Unlock()
|
|
writeJSON(w, 200, map[string]any{"conversations": items})
|
|
}
|
|
|
|
func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var req struct {
|
|
AgentKey string `json:"agent_key"`
|
|
Title string `json:"title"`
|
|
LLMProfileID *int64 `json:"llm_profile_id"`
|
|
TargetGate bool `json:"target_gate"` // approval gate for asset targets, opt-in
|
|
}
|
|
if !decodeConversationRequest(w, r, &req) {
|
|
return
|
|
}
|
|
req.AgentKey = strings.TrimSpace(req.AgentKey)
|
|
if req.AgentKey == "" {
|
|
writeErr(w, 400, "agent_key cannot be empty")
|
|
return
|
|
}
|
|
if utf8.RuneCountInString(req.AgentKey) > maxConversationAgentKeyRunes {
|
|
writeErr(w, 400, fmt.Sprintf("agent_key must be at most %d characters", maxConversationAgentKeyRunes))
|
|
return
|
|
}
|
|
a, err := pg.GetAgentByKey(req.AgentKey)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if a == nil {
|
|
writeErr(w, 404, "agent not found")
|
|
return
|
|
}
|
|
if req.LLMProfileID != nil {
|
|
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
|
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
|
return
|
|
}
|
|
}
|
|
title := strings.TrimSpace(req.Title)
|
|
if title == "" {
|
|
title = "New conversation"
|
|
}
|
|
if utf8.RuneCountInString(title) > maxConversationTitleRunes {
|
|
writeErr(w, 400, fmt.Sprintf("Title must be at most %d characters", maxConversationTitleRunes))
|
|
return
|
|
}
|
|
c, err := pg.CreateConversationGated(req.AgentKey, title, req.LLMProfileID, req.TargetGate)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, c)
|
|
}
|
|
|
|
func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
LLMProfileID *int64 `json:"llm_profile_id"` // null clears the override
|
|
}
|
|
if !decodeConversationRequest(w, r, &req) {
|
|
return
|
|
}
|
|
if req.LLMProfileID != nil {
|
|
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
|
writeErr(w, 400, "The specified LLM profile does not exist or is not usable (no API key for the key-requiring formats)")
|
|
return
|
|
}
|
|
}
|
|
if err := pg.UpdateConversationProfile(c.ID, req.LLMProfileID); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"ok": true})
|
|
}
|
|
|
|
// convByID resolves the {id} path value to a conversation (404 if absent).
|
|
func (s *Server) convByID(w http.ResponseWriter, r *http.Request) (*db.DB, *db.Conversation, bool) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return nil, nil, false
|
|
}
|
|
id, ok := pathInt(r, "id")
|
|
if !ok {
|
|
writeErr(w, 400, "bad conversation id")
|
|
return nil, nil, false
|
|
}
|
|
c, err := pg.GetConversation(id)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return nil, nil, false
|
|
}
|
|
if c == nil {
|
|
writeErr(w, 404, "conversation not found")
|
|
return nil, nil, false
|
|
}
|
|
return pg, c, true
|
|
}
|
|
|
|
func (s *Server) pgRenameConversation(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Title *string `json:"title"`
|
|
Pinned *bool `json:"pinned"`
|
|
TargetGate *bool `json:"target_gate"`
|
|
}
|
|
if !decodeConversationRequest(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Title == nil && req.Pinned == nil && req.TargetGate == nil {
|
|
writeErr(w, 400, "Provide at least title, pinned or target_gate")
|
|
return
|
|
}
|
|
if req.Title != nil {
|
|
title := strings.TrimSpace(*req.Title)
|
|
if title == "" {
|
|
writeErr(w, 400, "Title cannot be empty")
|
|
return
|
|
}
|
|
if utf8.RuneCountInString(title) > maxConversationTitleRunes {
|
|
writeErr(w, 400, fmt.Sprintf("Title must be at most %d characters", maxConversationTitleRunes))
|
|
return
|
|
}
|
|
req.Title = &title
|
|
}
|
|
updated, err := pg.UpdateConversation(c.ID, db.ConversationPatch{Title: req.Title, Pinned: req.Pinned, TargetGate: req.TargetGate})
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
if updated == nil {
|
|
writeErr(w, 404, "conversation not found")
|
|
return
|
|
}
|
|
writeJSON(w, 200, updated)
|
|
}
|
|
|
|
func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
s.cancelConversation(c.ID)
|
|
s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.ConversationID == c.ID })
|
|
if err := pg.DeleteConversation(c.ID); err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"deleted": c.ID})
|
|
}
|
|
|
|
const maxConversationDeleteBatch = 100
|
|
|
|
type conversationDeleteItem struct {
|
|
ID int64 `json:"id"`
|
|
OK bool `json:"ok"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
func (s *Server) cancelConversation(id int64) {
|
|
busyKey := s.convBusyKey(id)
|
|
s.chatMu.Lock()
|
|
cancel := s.chatCancel[busyKey]
|
|
s.chatMu.Unlock()
|
|
if cancel != nil {
|
|
cancel(agent.AbortChatStoppedByUser)
|
|
}
|
|
}
|
|
|
|
// pgDeleteConversationsBatch deletes up to 100 selected conversations in one
|
|
// database statement. Missing ids are reported per item so a stale list does not
|
|
// hide what was actually removed.
|
|
func (s *Server) pgDeleteConversationsBatch(w http.ResponseWriter, r *http.Request) {
|
|
pg := s.pg(w)
|
|
if pg == nil {
|
|
return
|
|
}
|
|
var request struct {
|
|
IDs []int64 `json:"ids"`
|
|
}
|
|
if !decodeConversationRequest(w, r, &request) {
|
|
return
|
|
}
|
|
ids := make([]int64, 0, len(request.IDs))
|
|
seen := make(map[int64]struct{}, len(request.IDs))
|
|
for _, id := range request.IDs {
|
|
if id <= 0 {
|
|
writeErr(w, http.StatusBadRequest, "invalid conversation id")
|
|
return
|
|
}
|
|
if _, exists := seen[id]; exists {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 || len(ids) > maxConversationDeleteBatch {
|
|
writeErr(w, http.StatusBadRequest, fmt.Sprintf("ids must contain 1-%d entries", maxConversationDeleteBatch))
|
|
return
|
|
}
|
|
for _, id := range ids {
|
|
s.cancelConversation(id)
|
|
s.cancelSideWhere(func(p sidequestion.Parent) bool { return p.ConversationID == id })
|
|
}
|
|
deleted, err := pg.DeleteConversations(ids)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
deletedSet := make(map[int64]struct{}, len(deleted))
|
|
for _, id := range deleted {
|
|
deletedSet[id] = struct{}{}
|
|
}
|
|
items := make([]conversationDeleteItem, 0, len(ids))
|
|
for _, id := range ids {
|
|
_, ok := deletedSet[id]
|
|
item := conversationDeleteItem{ID: id, OK: ok}
|
|
if !ok {
|
|
item.Error = "conversation not found"
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
|
}
|
|
|
|
func (s *Server) convBusyKey(id int64) string { return "conv-" + strconv.FormatInt(id, 10) }
|
|
|
|
// pgConversationMessages returns steps after ?since=cursor plus whether a turn is
|
|
// still running (so the client knows to keep polling). Same item shape as the task
|
|
// activity stream, so the frontend transcript renderer is reused verbatim.
|
|
func (s *Server) pgConversationMessages(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
limit := atoiDefault(q.Get("limit"), 200)
|
|
s.chatMu.Lock()
|
|
running := s.chatBusy[s.convBusyKey(c.ID)]
|
|
s.chatMu.Unlock()
|
|
|
|
// Incremental tail: ?since=N returns steps after id N (ASC) — used by the live
|
|
// poll and the post-send fetch. A generous cap so a burst is never dropped.
|
|
if sv := q.Get("since"); sv != "" {
|
|
since, _ := strconv.ParseInt(sv, 10, 64)
|
|
items, cursor, err := pg.ConvActivityList(c.ID, since, max(limit, 1000))
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": false})
|
|
return
|
|
}
|
|
|
|
// History page (reverse pagination): no ?since → the latest page; ?before=N →
|
|
// the page of older steps ending before id N. hasMore lets the client stop
|
|
// loading earlier history once the top of the thread is reached.
|
|
before, _ := strconv.ParseInt(q.Get("before"), 10, 64)
|
|
items, hasMore, err := pg.ConvActivityPage(c.ID, before, limit)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
cursor := before
|
|
if n := len(items); n > 0 {
|
|
cursor = items[n-1].ID
|
|
}
|
|
writeJSON(w, 200, map[string]any{"items": activityDTOs(items), "cursor": cursor, "running": running, "hasMore": hasMore})
|
|
}
|
|
|
|
// pgConversationMsgDetail lazily returns one step's full detail blob.
|
|
func (s *Server) pgConversationMsgDetail(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
seq, ok := pathInt(r, "seq")
|
|
if !ok {
|
|
writeErr(w, 400, "bad seq")
|
|
return
|
|
}
|
|
detail, err := pg.ConvActivityDetail(c.ID, seq)
|
|
if err != nil {
|
|
writeErr(w, 500, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, 200, map[string]any{"detail": detail})
|
|
}
|
|
|
|
// pgStopConversation aborts the in-flight run for one conversation (manual stop —
|
|
// the run/stop button in the chat UI). It cancels only THIS session's agent run;
|
|
// the P3 trigger queue is untouched, so the agent's next queued fire still starts.
|
|
func (s *Server) pgStopConversation(w http.ResponseWriter, r *http.Request) {
|
|
_, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
busyKey := s.convBusyKey(c.ID)
|
|
s.chatMu.Lock()
|
|
cancel := s.chatCancel[busyKey]
|
|
s.chatMu.Unlock()
|
|
if cancel == nil {
|
|
writeJSON(w, 200, map[string]any{"status": "idle"}) // nothing running
|
|
return
|
|
}
|
|
cancel(agent.AbortChatStoppedByUser)
|
|
writeJSON(w, 200, map[string]any{"status": "stopping"})
|
|
}
|
|
|
|
// pgSendConversationMessage persists the human turn, then runs the agent in the
|
|
// background (steps stream to conversation_activities, polled by the client). One
|
|
// in-flight turn per conversation.
|
|
func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Request) {
|
|
pg, c, ok := s.convByID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Message string `json:"message"`
|
|
Attachments []chatAttachment `json:"attachments,omitempty"` // files uploaded via method 1 (paths relative to the conversation working dir)
|
|
}
|
|
if err := decode(r, &req); err != nil {
|
|
writeErr(w, 400, err.Error())
|
|
return
|
|
}
|
|
msg := strings.TrimSpace(req.Message)
|
|
if msg == "" && len(req.Attachments) == 0 {
|
|
writeErr(w, 400, "Message cannot be empty")
|
|
return
|
|
}
|
|
agentMessage, ok := s.prepareChatMentionMessage(w, msg)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Same resolution as the background runner: honour the agent binding / this
|
|
// conversation's chosen profile before falling back to the global config, so a
|
|
// conversation that picked a valid LLM is not rejected just because no global
|
|
// config is active.
|
|
if s.resolveChatAgent(c) == nil {
|
|
writeErr(w, 503, s.chatUnavailableReason())
|
|
return
|
|
}
|
|
|
|
busyKey := s.convBusyKey(c.ID)
|
|
s.chatMu.Lock()
|
|
if s.chatBusy[busyKey] {
|
|
s.chatMu.Unlock()
|
|
writeErr(w, 409, "This conversation is still processing the previous message; please wait")
|
|
return
|
|
}
|
|
s.chatBusy[busyKey] = true
|
|
s.chatMu.Unlock()
|
|
|
|
// persist + float the human turn; auto-title the thread from the first message.
|
|
// Worker is the agent key (not "user") so the transcript stays a single lane
|
|
// (no worker chips) — the kind='user' already right-aligns it as a human bubble.
|
|
// With attachments, Detail carries {text, attachments} JSON so the transcript
|
|
// renders attachment cards; without, Detail is the full text (Summary is truncated,
|
|
// so the transcript lazy-loads Detail to show the message untruncated).
|
|
ua := userActivityWithAttachments(c.AgentKey, msg, req.Attachments)
|
|
ua.Summary = firstLine(msg, 200)
|
|
if ua.Detail == "" {
|
|
ua.Detail = msg
|
|
}
|
|
if _, err := pg.AppendConvActivity(c.ID, ua); err != nil {
|
|
log.Printf("[conv %d] append user msg failed: %v", c.ID, err)
|
|
}
|
|
if c.Title == "" || c.Title == "New conversation" {
|
|
title := firstLine(msg, 40)
|
|
if title == "" {
|
|
title = "Message with attachments"
|
|
}
|
|
_ = pg.RenameConversation(c.ID, title)
|
|
}
|
|
_ = pg.TouchConversation(c.ID)
|
|
|
|
// Append the uploaded files' ABSOLUTE paths so the ChatAgent opens them with its
|
|
// Read/Bash tools. baseDir = the agent's per-session CWD (<workDir>/sessions/
|
|
// conv-<id>/), matching chatUpload's landing dir and agent/chat.go's sessionWorkDir
|
|
// — busyKey == convBusyKey(c.ID) == "conv-<id>" == that session id.
|
|
baseDir := filepath.Join(s.m.dir, "sessions", busyKey)
|
|
s.runConversation(c, composeAgentMessage(agentMessage, req.Attachments, baseDir), busyKey, msg)
|
|
writeJSON(w, 202, map[string]any{"status": "accepted"})
|
|
}
|
|
|
|
// runConversation runs ONE agent turn on a conversation in a background goroutine
|
|
// (each conversation is independent → parallel). Steps stream to
|
|
// conversation_activities. Shared by the chat HTTP handler and the P3 scheduler.
|
|
// busyKey clears when the run ends (best-effort in-flight marker).
|
|
func (s *Server) runConversation(c *db.Conversation, msg, busyKey string, userMessage ...string) {
|
|
ctx, cancel := s.conversationRunContext(c.ID, busyKey)
|
|
// Only the human-message handler supplies this field, before adding the
|
|
// attachment manifest. Scheduler/retest prompts must not be labelled as users.
|
|
if len(userMessage) == 1 {
|
|
ctx = intercept.WithReviewContext(ctx, "", intercept.ReviewBackground{Source: intercept.BackgroundUserMessage, Text: userMessage[0]})
|
|
}
|
|
go s.runConversationTurn(ctx, cancel, c, msg, busyKey)
|
|
}
|
|
|
|
// runConversationSync runs ONE agent turn on a conversation and BLOCKS until the
|
|
// run ends (clearing busyKey). runConversation wraps it in a goroutine for the
|
|
// fire-and-forget chat path; the P3 trigger queue calls it directly so it can wait
|
|
// for completion before starting the next queued fire for the same agent.
|
|
func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) {
|
|
ctx, cancel := s.conversationRunContext(c.ID, busyKey)
|
|
s.runConversationTurn(ctx, cancel, c, msg, busyKey)
|
|
}
|
|
|
|
// Register cancellation before returning 202, so an immediate stop/delete cannot
|
|
// miss a background goroutine which has not started yet.
|
|
func (s *Server) conversationRunContext(id int64, busyKey string) (context.Context, context.CancelCauseFunc) {
|
|
// Per-run cancellable context so a manual stop (pgStopConversation) can abort
|
|
// just this session. Registered under chatMu so the stop handler can find it.
|
|
ctx, cancel := context.WithCancelCause(intercept.WithConvID(s.ctx, id))
|
|
s.chatMu.Lock()
|
|
s.chatCancel[busyKey] = cancel
|
|
s.chatMu.Unlock()
|
|
return ctx, cancel
|
|
}
|
|
|
|
func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelCauseFunc, c *db.Conversation, msg, busyKey string) {
|
|
defer func() {
|
|
cancel(agent.AbortChatTurnFinished)
|
|
s.chatMu.Lock()
|
|
delete(s.chatBusy, busyKey)
|
|
delete(s.chatCancel, busyKey)
|
|
s.chatMu.Unlock()
|
|
}()
|
|
// Only the first turn executes a historical retest. Follow-up conversation
|
|
// turns may explain the sealed result; the result tool refuses to overwrite it.
|
|
finishStatus, finishReason := "failed", "retest failed to start"
|
|
if c.AgentKey == db.FindingRetestAgentKey {
|
|
// Read without the run cancellation so an immediate stop still seals pending.
|
|
r, err := s.m.pg.FindingRetestForConversation(context.Background(), c.ID)
|
|
if err != nil {
|
|
// The sealing defer below needs r.ID, which we do not have here. Seal by
|
|
// conversation instead, otherwise the row stays 'pending' forever.
|
|
log.Printf("[conv %d] load retest: %v", c.ID, err)
|
|
if err := s.m.pg.FailPendingRetestForConversation(c.ID, "failed to read retest status; please start it again"); err != nil {
|
|
log.Printf("[conv %d] seal retest: %v", c.ID, err)
|
|
}
|
|
return
|
|
}
|
|
if r != nil && r.Status == "pending" {
|
|
defer func() {
|
|
if ctx.Err() != nil {
|
|
finishStatus, finishReason = "stopped", "retest stopped or service shut down"
|
|
}
|
|
s.finishRetest(r.ID, finishStatus, finishReason)
|
|
}()
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
started, err := s.m.pg.StartFindingRetest(ctx, r.ID)
|
|
if err != nil {
|
|
finishReason = err.Error()
|
|
return
|
|
}
|
|
if !started {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
// precedence: the conversation agent's own binding → this conversation's pin → global.
|
|
ca := s.resolveChatAgent(c)
|
|
if ca == nil {
|
|
finishReason = s.chatUnavailableReason()
|
|
return
|
|
}
|
|
pg := s.m.pg
|
|
maxTurns := s.agentMaxTurns(c.AgentKey)
|
|
maxDuration := time.Duration(s.agentRunSeconds(c.AgentKey)) * time.Second
|
|
webSearch := false
|
|
if a, err := pg.GetAgentByKey(c.AgentKey); err == nil && a != nil {
|
|
webSearch = a.WebSearch
|
|
}
|
|
sessionID := s.convBusyKey(c.ID) // "conv-<id>" transcript session
|
|
emit := func(rec db.Activity) {
|
|
if _, err := pg.AppendConvActivity(c.ID, rec); err != nil {
|
|
log.Printf("[conv %d] append activity failed: %v", c.ID, err)
|
|
}
|
|
}
|
|
// Approval scope of this turn's tool calls for the target gate (chat and retester).
|
|
// The conversation's opt-in flag is the one read when the turn started: a
|
|
// switch through the API applies from the next turn.
|
|
gate := c.TargetGate
|
|
gctx := intercept.WithTargetScope(ctx, intercept.TargetScope{ConversationID: c.ID, Enabled: func() bool { return gate },
|
|
WaitBound: intercept.DefaultTargetWaitBound, Interceptor: s.m.interceptor})
|
|
// On a manual stop ctx is cancelled; Chat already emits a clean "stopped by the user"
|
|
// step, so skip the raw-error entry — only surface genuine failures.
|
|
if _, err := ca.Chat(gctx, c.AgentKey, sessionID, msg, maxTurns, maxDuration, webSearch, emit); err != nil {
|
|
finishReason = err.Error()
|
|
if ctx.Err() == nil {
|
|
_, _ = pg.AppendConvActivity(c.ID, db.Activity{Worker: c.AgentKey, Kind: "text", IsError: true,
|
|
Summary: "(error: " + err.Error() + ")", Detail: err.Error()})
|
|
}
|
|
} else {
|
|
finishStatus, finishReason = "completed", ""
|
|
}
|
|
_ = pg.TouchConversation(c.ID)
|
|
}
|
|
|
|
// triggerBehavior is an agent's cached P3 trigger post-processing policy (see the
|
|
// agents table trigger_* columns). Read once per fire in StartTriggeredRun so the
|
|
// pump never touches the DB while holding queueMu.
|
|
type triggerBehavior struct {
|
|
runMode string // serial | parallel
|
|
mergeMode string // by_task | all | none (used by serial; parallel ignores it, each fire gets its own conversation)
|
|
maxParallel int // per-agent concurrency cap used by parallel; <=0 = unlimited
|
|
}
|
|
|
|
// readTriggerBehavior loads an agent's policy, falling back to safe defaults
|
|
// (serial / all / 5) on any error or unknown enum value.
|
|
func (s *Server) readTriggerBehavior(agentKey string) triggerBehavior {
|
|
b := triggerBehavior{runMode: "serial", mergeMode: "all", maxParallel: 5}
|
|
if s.m.pg == nil {
|
|
return b
|
|
}
|
|
a, err := s.m.pg.GetAgentByKey(agentKey)
|
|
if err != nil || a == nil {
|
|
return b
|
|
}
|
|
if a.TriggerRunMode == "parallel" {
|
|
b.runMode = "parallel"
|
|
}
|
|
switch a.TriggerMergeMode {
|
|
case "by_task", "all", "none":
|
|
b.mergeMode = a.TriggerMergeMode
|
|
}
|
|
b.maxParallel = a.TriggerMaxParallel
|
|
return b
|
|
}
|
|
|
|
// StartTriggeredRun enqueues a P3 trigger fire for agentKey and pumps the queue. The
|
|
// agent's policy decides concurrency + merge: serial → run one at a time (optionally
|
|
// merging by task / all / none); parallel → run each fire in its own concurrent
|
|
// conversation up to trigger_max_parallel. Distinct agents always run concurrently.
|
|
func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string) {
|
|
if s.m.pg == nil || s.chatAgentRef() == nil {
|
|
return
|
|
}
|
|
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
|
|
s.queueMu.Lock()
|
|
s.triggerCfg[agentKey] = cfg
|
|
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
|
|
s.pumpLocked(agentKey)
|
|
s.queueMu.Unlock()
|
|
}
|
|
|
|
// pumpLocked launches queued fires for one agent up to its concurrency limit, then
|
|
// returns. Serial → limit 1; parallel → limit = maxParallel (<=0 → unlimited). Each
|
|
// launched run re-pumps on completion (runAndPump) to fill the freed slot. Caller
|
|
// holds queueMu.
|
|
func (s *Server) pumpLocked(agentKey string) {
|
|
if s.ctx.Err() != nil {
|
|
return
|
|
}
|
|
cfg := s.triggerCfg[agentKey]
|
|
limit := 1
|
|
if cfg.runMode == "parallel" {
|
|
if cfg.maxParallel <= 0 {
|
|
limit = math.MaxInt
|
|
} else {
|
|
limit = cfg.maxParallel
|
|
}
|
|
}
|
|
for s.triggerActive[agentKey] < limit && len(s.triggerQ[agentKey]) > 0 {
|
|
item := s.nextTriggerRun(agentKey, cfg)
|
|
s.triggerActive[agentKey]++
|
|
go s.runAndPump(agentKey, item)
|
|
}
|
|
}
|
|
|
|
// runAndPump runs one fire to completion, then decrements the active counter and
|
|
// re-pumps to fill the freed slot (cleaning up the agent's maps when fully idle).
|
|
func (s *Server) runAndPump(agentKey string, item triggeredRun) {
|
|
s.runTriggeredRun(item)
|
|
s.queueMu.Lock()
|
|
s.triggerActive[agentKey]--
|
|
if s.triggerActive[agentKey] <= 0 && len(s.triggerQ[agentKey]) == 0 {
|
|
delete(s.triggerActive, agentKey)
|
|
delete(s.triggerQ, agentKey)
|
|
delete(s.triggerCfg, agentKey)
|
|
} else {
|
|
s.pumpLocked(agentKey)
|
|
}
|
|
s.queueMu.Unlock()
|
|
}
|
|
|
|
// nextTriggerRun pops the next run from the agent's queue per the merge mode:
|
|
//
|
|
// parallel / none → one head item, no merge
|
|
// by_task → head + all queued same-task mergeable fires merged into one
|
|
// all → the ENTIRE queue merged into one run (regardless of task/type)
|
|
//
|
|
// Caller holds queueMu.
|
|
func (s *Server) nextTriggerRun(agentKey string, cfg triggerBehavior) triggeredRun {
|
|
q := s.triggerQ[agentKey]
|
|
if cfg.runMode == "parallel" || cfg.mergeMode == "none" {
|
|
s.triggerQ[agentKey] = q[1:]
|
|
return q[0]
|
|
}
|
|
if cfg.mergeMode == "all" {
|
|
s.triggerQ[agentKey] = q[:0:0]
|
|
return mergeAllRuns(q)
|
|
}
|
|
// by_task: head + same-task mergeable fires.
|
|
head := q[0]
|
|
if !head.mergeable || head.taskID == 0 {
|
|
s.triggerQ[agentKey] = q[1:]
|
|
return head
|
|
}
|
|
group := []triggeredRun{head}
|
|
rest := q[:0:0] // keep non-matching items in order
|
|
for _, it := range q[1:] {
|
|
if it.mergeable && it.taskID == head.taskID {
|
|
group = append(group, it)
|
|
} else {
|
|
rest = append(rest, it)
|
|
}
|
|
}
|
|
s.triggerQ[agentKey] = rest
|
|
return mergeTriggeredRuns(group)
|
|
}
|
|
|
|
// taskContextHeader renders a task's description/goal once. Same-task fires share
|
|
// this block, so the scheduler no longer repeats it per event (a long task goal
|
|
// times N fires was the dominant bloat). Returns "" for interval/none triggers
|
|
// (taskID==0, no task context). desc/goal are truncated to keep even a single copy
|
|
// bounded.
|
|
func taskContextHeader(taskID int64, desc, goal string) string {
|
|
if desc == "" && goal == "" {
|
|
// No task context to show (interval/none fire, or a merged run that already
|
|
// embedded its per-task headers and cleared these fields).
|
|
return ""
|
|
}
|
|
if goal != "" {
|
|
return fmt.Sprintf("[Task #%d %s (goal: %s)]", taskID, trunc(desc, 200), trunc(goal, 500))
|
|
}
|
|
return fmt.Sprintf("[Task #%d %s]", taskID, trunc(desc, 200))
|
|
}
|
|
|
|
// finalTriggerMessage renders the message actually sent to the agent for a single
|
|
// (non-merged) fire: the task-context header (written once) followed by the event
|
|
// body. Merged runs embed their per-task headers inline and clear taskDesc/taskGoal,
|
|
// so this returns their message unchanged.
|
|
func finalTriggerMessage(item triggeredRun) string {
|
|
if h := taskContextHeader(item.taskID, item.taskDesc, item.taskGoal); h != "" {
|
|
return h + "\n" + item.message
|
|
}
|
|
return item.message
|
|
}
|
|
|
|
// mergeTriggeredRuns folds several same-task event fires into one run: the shared
|
|
// task-context header written ONCE, then each fire's event body, so the agent handles
|
|
// the task's burst in a single conversation without repeating the (possibly long)
|
|
// task description/goal per event.
|
|
func mergeTriggeredRuns(items []triggeredRun) triggeredRun {
|
|
if len(items) == 1 {
|
|
return items[0]
|
|
}
|
|
first := items[0]
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[This conversation merges task #%d's %d trigger events; handle them together]\n", first.taskID, len(items))
|
|
if h := taskContextHeader(first.taskID, first.taskDesc, first.taskGoal); h != "" {
|
|
fmt.Fprintf(&b, "%s\n", h) // same task → task context appears once
|
|
}
|
|
for i, it := range items {
|
|
fmt.Fprintf(&b, "\n── trigger %d ──\n%s\n", i+1, it.message)
|
|
}
|
|
return triggeredRun{
|
|
agentKey: first.agentKey,
|
|
title: fmt.Sprintf("Merged triggers · task#%d · %d items", first.taskID, len(items)),
|
|
message: b.String(),
|
|
taskID: first.taskID,
|
|
mergeable: true,
|
|
// taskDesc/taskGoal left empty: header already embedded above.
|
|
}
|
|
}
|
|
|
|
// mergeAllRuns folds the ENTIRE queued burst into one run (merge_mode='all'),
|
|
// regardless of type. Events are grouped by task (tasks in first-appearance order,
|
|
// events in their original order within a task), so each task's context header — and
|
|
// its possibly long description/goal — is written exactly ONCE even when fires from
|
|
// different tasks interleave in the queue. Cross-task chronology is not preserved
|
|
// (event bodies carry no timestamps, so per-task grouping reads better for the agent).
|
|
func mergeAllRuns(items []triggeredRun) triggeredRun {
|
|
if len(items) == 1 {
|
|
return items[0]
|
|
}
|
|
first := items[0]
|
|
order := []int64{}
|
|
groups := map[int64][]triggeredRun{}
|
|
for _, it := range items {
|
|
if _, seen := groups[it.taskID]; !seen {
|
|
order = append(order, it.taskID)
|
|
}
|
|
groups[it.taskID] = append(groups[it.taskID], it)
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "[This conversation merges %d queued trigger events across %d tasks; handle them together]\n", len(items), len(order))
|
|
seq := 0
|
|
for _, tid := range order {
|
|
g := groups[tid]
|
|
if h := taskContextHeader(tid, g[0].taskDesc, g[0].taskGoal); h != "" {
|
|
fmt.Fprintf(&b, "\n%s\n", h) // same task → context appears once, even if interleaved
|
|
}
|
|
for _, it := range g {
|
|
seq++
|
|
fmt.Fprintf(&b, "\n── trigger %d (task#%d) ──\n%s\n", seq, tid, it.message)
|
|
}
|
|
}
|
|
return triggeredRun{
|
|
agentKey: first.agentKey,
|
|
title: fmt.Sprintf("Merged triggers · all · %d items", len(items)),
|
|
message: b.String(),
|
|
taskID: first.taskID,
|
|
mergeable: true,
|
|
// taskDesc/taskGoal left empty: per-task headers already embedded above.
|
|
}
|
|
}
|
|
|
|
// runTriggeredRun creates the conversation for one queued fire, records the human
|
|
// turn, and runs it synchronously (blocks until the run ends). recover keeps a
|
|
// panic from killing the drain loop and wedging the agent's queue.
|
|
func (s *Server) runTriggeredRun(item triggeredRun) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[trigger] run for %s panicked: %v", item.agentKey, r)
|
|
}
|
|
}()
|
|
pg := s.m.pg
|
|
c, err := pg.CreateConversation(item.agentKey, firstLine(item.title, 60), nil)
|
|
if err != nil {
|
|
log.Printf("[trigger] create conversation for %s failed: %v", item.agentKey, err)
|
|
return
|
|
}
|
|
msg := finalTriggerMessage(item) // prepend the task-context header for single (non-merged) fires
|
|
if _, err := pg.AppendConvActivity(c.ID, db.Activity{Worker: item.agentKey, Kind: "user", Summary: firstLine(msg, 200), Detail: msg}); err != nil {
|
|
log.Printf("[trigger] append msg failed: %v", err)
|
|
}
|
|
busyKey := s.convBusyKey(c.ID)
|
|
s.chatMu.Lock()
|
|
s.chatBusy[busyKey] = true
|
|
s.chatMu.Unlock()
|
|
s.runConversationSync(c, msg, busyKey)
|
|
}
|
|
|
|
// firstLine returns a single-line, length-capped preview (shared with summaries).
|
|
func firstLine(s string, max int) string {
|
|
s = strings.TrimSpace(s)
|
|
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
|
s = s[:i]
|
|
}
|
|
if len([]rune(s)) > max {
|
|
s = string([]rune(s)[:max]) + "…"
|
|
}
|
|
return s
|
|
}
|