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, or an action only they can perform). 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. When the block is environmental (a sandboxed or filtered network path, a missing binary or key, a proxy or TLS failure this runtime cannot fix), do not retry or work around it: include in the question the exact single command the operator should run in their own terminal, as one copy-pastable line, plus what to paste back, then wait.", 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'). For environmental blocks: the exact single command they should run in their own terminal (one line, copy-pastable) and what to paste back.", }, "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 }