mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
A worker hitting a wall only a human can clear (captcha, email/SMS OTP, 2FA,
a decision) calls ask_operator with a precise question and an optional
screenshot. The question lands in the intent's transcript as a user-role turn
with an attachment card (the same {text, attachments} shape chat uploads use,
so the existing transcript UI renders it natively), and the intent pauses. The
operator replies through the existing message-to-worker flow; the reply resumes
the run with the answer as the next input. Images are whitelisted (png/jpg/
jpeg/webp/gif), capped at 10MB, and copied into the task uploads dir.
Closes #41
119 lines
4.7 KiB
Go
119 lines
4.7 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Autumn-27/artex/agent"
|
|
actool "github.com/Autumn-27/norma/tool"
|
|
)
|
|
|
|
const askOperatorMaxImage = 10 << 20
|
|
|
|
var askOperatorImageExt = map[string]bool{".png": true, ".jpg": true, ".jpeg": true, ".webp": true, ".gif": true}
|
|
|
|
// toolAskOperator lets a worker formally request operator input (captcha, OTP,
|
|
// 2FA, a decision): the question and an optional screenshot land in the intent's
|
|
// transcript as a user-role turn with an attachment card, and the intent pauses.
|
|
// The operator replies through the existing message-to-worker flow; the reply
|
|
// resumes the run with the answer as the next input (#41).
|
|
func (s *Server) toolAskOperator() actool.CoreTool {
|
|
return wrTool("ask_operator",
|
|
"Ask the human operator for input you cannot obtain yourself (a captcha solution, an email/SMS OTP code, a 2FA answer, a decision). The question appears in this intent's chat for the operator, optionally with a screenshot, and this intent then pauses; the operator's reply resumes this run with the answer as your next input. Ask precisely, and only when you are genuinely blocked.",
|
|
map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"question": map[string]any{
|
|
"type": "string",
|
|
"description": "What you need from the operator, precisely (e.g. 'enter the 6-digit code sent to x@y', 'solve the captcha in the attached screenshot').",
|
|
},
|
|
"image_path": map[string]any{
|
|
"type": "string",
|
|
"description": "Optional path to a screenshot (png/jpg/jpeg/webp/gif, max 10MB), e.g. the browser_take_screenshot output. It is copied into the task uploads and shown to the operator.",
|
|
},
|
|
},
|
|
"required": []any{"question"},
|
|
},
|
|
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
|
|
var req struct {
|
|
Question string `json:"question"`
|
|
ImagePath string `json:"image_path"`
|
|
}
|
|
_ = json.Unmarshal(in, &req)
|
|
q := strings.TrimSpace(req.Question)
|
|
if q == "" {
|
|
return actool.Errorf("Error: question is required"), nil
|
|
}
|
|
ri := agent.RunInfoFrom(ctx)
|
|
if ri.TaskID == 0 || ri.IntentID == 0 {
|
|
return actool.Errorf("Error: ask_operator can only be used inside a worker intent run"), nil
|
|
}
|
|
taskID := fmt.Sprintf("%d", ri.TaskID)
|
|
t := s.m.ResolveTask(taskID)
|
|
if t == nil {
|
|
return actool.Errorf("Error: task not found"), nil
|
|
}
|
|
att, err := stageAskOperatorImage(s.m.dir, taskID, req.ImagePath)
|
|
if err != nil {
|
|
return actool.Errorf("Error: " + err.Error()), nil
|
|
}
|
|
text := "OPERATOR INPUT REQUESTED: " + q + " (this intent is pausing; reply here to resume it with your answer)"
|
|
act := userActivityWithAttachments("worker", text, att)
|
|
node := ri.IntentID
|
|
act.NodeID = &node
|
|
s.engine.emitActivity(t, act)
|
|
// The control call runs on a detached context: pausing may cancel the
|
|
// run's context, and the pause bookkeeping itself must complete.
|
|
if _, err := s.applyIntentControl(context.Background(), t, ri.IntentID, "pause", firstLine(q, 120), ""); err != nil {
|
|
return actool.Errorf("Error: question delivered but pausing the intent failed: " + err.Error()), nil
|
|
}
|
|
return actool.Text("Question delivered to the operator. This intent is pausing now: finish your current step, record everything you already know as facts, and stop. The operator's reply will resume this run and arrive as your next input."), nil
|
|
})
|
|
}
|
|
|
|
// stageAskOperatorImage copies the referenced image into the task's uploads dir
|
|
// (the same place chat uploads land, so the transcript attachment card resolves)
|
|
// and returns the attachment descriptor. Empty src returns no attachment.
|
|
func stageAskOperatorImage(dataDir, taskID, src string) ([]chatAttachment, error) {
|
|
src = strings.TrimSpace(src)
|
|
if src == "" {
|
|
return nil, nil
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(src))
|
|
if !askOperatorImageExt[ext] {
|
|
return nil, fmt.Errorf("image_path must be a png/jpg/jpeg/webp/gif file, got %q", filepath.Base(src))
|
|
}
|
|
st, err := os.Stat(src)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("image not readable: %v", err)
|
|
}
|
|
if st.Size() > askOperatorMaxImage {
|
|
return nil, fmt.Errorf("image too large (%s, max 10MB)", humanBytes(st.Size()))
|
|
}
|
|
dir := filepath.Join(dataDir, "tasks", taskID, "uploads")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
name := fmt.Sprintf("ask-operator-%d%s", time.Now().Unix(), ext)
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer in.Close()
|
|
out, err := os.Create(filepath.Join(dir, name))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer out.Close()
|
|
if _, err := io.Copy(out, in); err != nil {
|
|
return nil, err
|
|
}
|
|
return []chatAttachment{{Name: name, Path: "uploads/" + name, Size: st.Size()}}, nil
|
|
}
|